1#![allow(clippy::doc_markdown)]
16#![doc = include_str!("../README.md")]
17#![forbid(unsafe_code)]
52
53use serde::{Deserialize, Serialize};
54use std::path::{Path, PathBuf};
55use thiserror::Error;
56use tracing::{debug, instrument};
57
58#[cfg(test)]
59#[path = "detection_tests.rs"]
60#[allow(
61 clippy::unwrap_used,
62 clippy::expect_used,
63 clippy::too_many_lines,
64 clippy::missing_panics_doc
65)]
66mod detection_tests;
67
68#[derive(Debug, Error)]
70pub enum IsoError {
71 #[error("IO error: {0}")]
74 Io(#[from] std::io::Error),
75
76 #[error("No boot entries found in ISO: {0}")]
79 NoBootEntries(String),
80
81 #[error("Mount failed: {0}")]
84 MountFailed(String),
85
86 #[error("Path traversal attempt blocked: {0}")]
90 PathTraversal(String),
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
95pub struct BootEntry {
96 pub label: String,
98 pub kernel: PathBuf,
100 pub initrd: Option<PathBuf>,
102 pub kernel_args: Option<String>,
104 pub distribution: Distribution,
106 pub source_iso: String,
108 #[serde(default)]
116 pub pretty_name: Option<String>,
117}
118
119#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
125pub enum Distribution {
126 Arch,
128 Debian,
130 Fedora,
132 RedHat,
135 Alpine,
137 NixOS,
139 Windows,
145 Unknown,
147}
148
149impl Distribution {
150 #[must_use]
152 pub fn from_paths(kernel_path: &std::path::Path) -> Self {
153 let path_str = kernel_path.to_string_lossy().to_lowercase();
154
155 if path_str.contains("bootmgr")
160 || path_str.contains("sources/boot.wim")
161 || path_str.contains("efi/microsoft")
162 || path_str.contains("windows")
163 {
164 Distribution::Windows
165 } else if path_str.contains("nixos") || path_str.ends_with("bzimage") {
166 Distribution::NixOS
167 } else if path_str.contains("alpine")
168 || path_str.contains("vmlinuz-lts")
173 || path_str.contains("vmlinuz-virt")
174 || path_str.contains("initramfs-lts")
175 || path_str.contains("initramfs-virt")
176 {
177 Distribution::Alpine
178 } else if path_str.contains("rhel")
179 || path_str.contains("rocky")
180 || path_str.contains("almalinux")
181 || path_str.contains("centos")
182 {
183 Distribution::RedHat
184 } else if path_str.contains("fedora")
185 || path_str.contains("images")
186 || path_str.contains("pxeboot")
187 {
188 Distribution::Fedora
189 } else if path_str.contains("debian")
190 || path_str.contains("ubuntu")
191 || path_str.contains("casper")
192 {
193 Distribution::Debian
194 } else if path_str.contains("arch")
195 || (path_str.contains("boot")
196 && !path_str.contains("efi")
197 && !path_str.contains("images"))
198 {
199 Distribution::Arch
200 } else {
201 Distribution::Unknown
202 }
203 }
204}
205
206pub trait IsoEnvironment: Send + Sync {
211 fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<std::path::PathBuf>>;
218
219 fn exists(&self, path: &std::path::Path) -> bool;
221
222 fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata>;
229
230 fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError>;
239
240 fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError>;
248
249 fn validate_path(
269 &self,
270 base: &std::path::Path,
271 path: &std::path::Path,
272 ) -> Result<PathBuf, IsoError> {
273 if path
274 .components()
275 .any(|c| matches!(c, std::path::Component::ParentDir))
276 {
277 return Err(IsoError::PathTraversal(path.display().to_string()));
278 }
279 if !path.starts_with(base) {
280 return Err(IsoError::PathTraversal(path.display().to_string()));
281 }
282 Ok(path.to_path_buf())
283 }
284}
285
286pub struct OsIsoEnvironment {
288 mount_base: PathBuf,
289}
290
291impl OsIsoEnvironment {
292 #[must_use]
296 pub fn new() -> Self {
297 Self {
298 mount_base: PathBuf::from("/tmp/iso-parser-mounts"),
299 }
300 }
301
302 fn allocate_loop_device(iso_path: &std::path::Path) -> Option<String> {
308 use std::process::Command;
309
310 match Command::new("losetup")
312 .args(["-f", "--show", "-r", &iso_path.to_string_lossy()])
313 .output()
314 {
315 Ok(out) if out.status.success() => {
316 let dev = String::from_utf8_lossy(&out.stdout).trim().to_string();
317 if !dev.is_empty() && dev.starts_with("/dev/") {
318 return Some(dev);
319 }
320 tracing::warn!(
324 iso = %iso_path.display(),
325 stdout = %String::from_utf8_lossy(&out.stdout),
326 "iso-parser: util-linux losetup succeeded but returned no /dev/loop* device"
327 );
328 }
329 Ok(out) => {
330 tracing::warn!(
331 iso = %iso_path.display(),
332 exit = ?out.status.code(),
333 stderr = %String::from_utf8_lossy(&out.stderr),
334 "iso-parser: util-linux losetup -f --show failed; falling back to busybox scan"
335 );
336 }
337 Err(e) => {
338 tracing::warn!(
339 iso = %iso_path.display(),
340 error = %e,
341 "iso-parser: losetup exec failed (not on PATH?); falling back to busybox scan"
342 );
343 }
344 }
345
346 for n in 0..16 {
350 let dev = format!("/dev/loop{n}");
351 if !std::path::Path::new(&dev).exists() {
352 continue;
353 }
354 let query = match Command::new("losetup").arg(&dev).output() {
356 Ok(q) => q,
357 Err(e) => {
358 tracing::warn!(
359 dev = %dev,
360 error = %e,
361 "iso-parser: losetup query exec failed; skipping device"
362 );
363 continue;
364 }
365 };
366 if query.status.success() {
367 continue; }
369 match Command::new("losetup")
371 .args(["-r", &dev, &iso_path.to_string_lossy()])
372 .output()
373 {
374 Ok(attach) if attach.status.success() => return Some(dev),
375 Ok(attach) => {
376 tracing::warn!(
377 dev = %dev,
378 iso = %iso_path.display(),
379 exit = ?attach.status.code(),
380 stderr = %String::from_utf8_lossy(&attach.stderr),
381 "iso-parser: losetup attach failed; trying next device"
382 );
383 }
384 Err(e) => {
385 tracing::warn!(
386 dev = %dev,
387 iso = %iso_path.display(),
388 error = %e,
389 "iso-parser: losetup attach exec failed; giving up"
390 );
391 return None;
392 }
393 }
394 }
395 tracing::warn!(
396 iso = %iso_path.display(),
397 "iso-parser: exhausted /dev/loop0..15 without a free device; cannot mount ISO"
398 );
399 None
400 }
401}
402
403impl Default for OsIsoEnvironment {
404 fn default() -> Self {
405 Self::new()
406 }
407}
408
409impl IsoEnvironment for OsIsoEnvironment {
410 fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
411 let mut entries = std::fs::read_dir(path)?
412 .map(|e| e.map(|entry| entry.path()))
413 .collect::<Result<Vec<_>, _>>()?;
414 entries.sort();
415 Ok(entries)
416 }
417
418 fn exists(&self, path: &std::path::Path) -> bool {
419 path.exists()
420 }
421
422 fn metadata(&self, path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
423 std::fs::metadata(path)
424 }
425
426 fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
427 use std::process::Command;
428
429 let iso_name = iso_path
431 .file_stem()
432 .and_then(|s| s.to_str())
433 .unwrap_or("iso");
434
435 let mount_point = self.mount_base.join(format!("mount_{iso_name}"));
436 std::fs::create_dir_all(&mount_point)?;
437
438 let output = Command::new("mount")
444 .args([
445 "-o",
446 "loop,ro",
447 "-t",
454 "udf,iso9660",
455 &iso_path.to_string_lossy(),
456 &mount_point.to_string_lossy(),
457 ])
458 .output();
459
460 let loop_attempt_ok = match &output {
465 Ok(out) if out.status.success() => {
466 std::fs::read_dir(&mount_point)
470 .ok()
471 .and_then(|mut entries| entries.next())
472 .is_some()
473 }
474 _ => false,
475 };
476
477 if !loop_attempt_ok {
478 let loop_dev = Self::allocate_loop_device(iso_path);
485 if let Some(loop_dev) = loop_dev {
486 let mount_out = Command::new("mount")
487 .args([
488 "-r",
489 "-t",
490 "udf,iso9660",
491 &loop_dev,
492 &mount_point.to_string_lossy(),
493 ])
494 .output();
495 if let Ok(mo) = mount_out {
496 if mo.status.success() {
497 debug!(
498 "Mounted {} via losetup {} -> {:?}",
499 iso_path.display(),
500 loop_dev,
501 mount_point
502 );
503 return Ok(mount_point);
504 }
505 }
506 let _ = Command::new("losetup").args(["-d", &loop_dev]).output();
508 }
509 }
510
511 let mount_point_populated = || {
519 std::fs::read_dir(&mount_point)
520 .ok()
521 .and_then(|mut entries| entries.next())
522 .is_some()
523 };
524 match output {
525 Ok(out) if out.status.success() && mount_point_populated() => {
526 debug!("Mounted {} to {:?}", iso_path.display(), mount_point);
527 Ok(mount_point)
528 }
529 Ok(out) => {
530 let stderr = String::from_utf8_lossy(&out.stderr);
531 let reason = if out.status.success() {
535 format!(
536 "mount claimed success but {} is empty — \
537 filesystem type likely not auto-detected \
538 (stderr: {})",
539 mount_point.display(),
540 stderr.trim()
541 )
542 } else {
543 stderr.to_string()
544 };
545 let fuse_output = Command::new("fuseiso")
547 .arg(iso_path.to_string_lossy().as_ref())
548 .arg(mount_point.to_string_lossy().as_ref())
549 .output();
550
551 match fuse_output {
552 Ok(fuse_out) if fuse_out.status.success() && mount_point_populated() => {
553 debug!("Mounted {} via fuseiso", iso_path.display());
554 Ok(mount_point)
555 }
556 _ => {
557 let _ = std::fs::remove_dir(&mount_point);
559 Err(IsoError::MountFailed(reason))
560 }
561 }
562 }
563 Err(e) => Err(IsoError::Io(e)),
564 }
565 }
566
567 fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
568 use std::process::Command;
569
570 let umount_result = Command::new("umount").arg(mount_point).output();
572
573 match umount_result {
574 Ok(out) if out.status.success() => {
575 let _ = std::fs::remove_dir(mount_point);
576 Ok(())
577 }
578 _ => {
579 let fusermount = Command::new("fusermount")
581 .arg("-u")
582 .arg(mount_point)
583 .output();
584 match fusermount {
585 Ok(out) if out.status.success() => {
586 let _ = std::fs::remove_dir(mount_point);
587 Ok(())
588 }
589 _ => Err(IsoError::MountFailed(format!(
590 "Failed to unmount {}",
591 mount_point.display()
592 ))),
593 }
594 }
595 }
596 }
597}
598
599pub struct IsoParser<E: IsoEnvironment> {
603 env: E,
604}
605
606impl<E: IsoEnvironment> IsoParser<E> {
607 pub fn new(env: E) -> Self {
610 Self { env }
611 }
612
613 #[instrument(skip(self))]
629 #[allow(clippy::unused_async)]
630 pub async fn scan_directory(&self, path: &std::path::Path) -> Result<Vec<BootEntry>, IsoError> {
631 let mut entries = Vec::new();
632
633 let validated_path = self.env.validate_path(std::path::Path::new("/"), path)?;
635
636 debug!("Scanning directory: {:?}", validated_path);
637
638 let iso_files = self.find_iso_files(&validated_path)?;
639 let attempted = iso_files.len();
640 let mut skipped = 0usize;
641
642 for iso_path in iso_files {
643 debug!("Processing ISO: {:?}", iso_path);
644
645 match self.process_iso(&iso_path).await {
646 Ok(mut iso_entries) => entries.append(&mut iso_entries),
647 Err(e) => {
648 skipped += 1;
649 tracing::warn!(
653 iso = %iso_path.display(),
654 error = %e,
655 "iso-parser: skipped ISO (mount/parse failed)"
656 );
657 }
658 }
659 }
660
661 tracing::info!(
662 root = %validated_path.display(),
663 found_isos = attempted,
664 extracted_entries = entries.len(),
665 skipped_isos = skipped,
666 "iso-parser: scan summary"
667 );
668
669 if entries.is_empty() {
670 return Err(IsoError::NoBootEntries(
671 validated_path.to_string_lossy().to_string(),
672 ));
673 }
674
675 Ok(entries)
676 }
677
678 fn find_iso_files(&self, path: &std::path::Path) -> Result<Vec<PathBuf>, IsoError> {
680 let mut isos = Vec::new();
681
682 for entry in self.env.list_dir(path)? {
683 let entry_path = &entry;
684
685 if entry.is_dir() {
687 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
689
690 if !name.starts_with('.') && name != "proc" && name != "sys" && name != "dev" {
691 if let Ok(mut sub_isos) = self.find_iso_files(entry_path) {
692 isos.append(&mut sub_isos);
693 }
694 }
695 } else if let Some(ext) = entry.extension().and_then(|s| s.to_str()) {
696 if ext.eq_ignore_ascii_case("iso") {
697 isos.push(entry.clone());
698 }
699 }
700 }
701
702 Ok(isos)
703 }
704
705 async fn process_iso(&self, iso_path: &Path) -> Result<Vec<BootEntry>, IsoError> {
707 let mount_point = self.env.mount_iso(iso_path)?;
708
709 let result = self.extract_boot_entries(&mount_point, iso_path).await;
710
711 let _ = self.env.unmount(&mount_point);
713
714 result
715 }
716
717 #[allow(clippy::unused_async)]
719 async fn extract_boot_entries(
720 &self,
721 mount_point: &Path,
722 source_iso: &Path,
723 ) -> Result<Vec<BootEntry>, IsoError> {
724 let mut entries = Vec::new();
725
726 entries.extend(self.try_arch_layout(mount_point, source_iso)?);
728 entries.extend(self.try_debian_layout(mount_point, source_iso)?);
729 entries.extend(self.try_fedora_layout(mount_point, source_iso)?);
730 entries.extend(self.try_windows_layout(mount_point, source_iso)?);
731
732 let pretty = read_pretty_name(mount_point);
737 if pretty.is_some() {
738 for entry in &mut entries {
739 entry.pretty_name.clone_from(&pretty);
740 }
741 }
742
743 Ok(entries)
744 }
745
746 fn try_arch_layout(
748 &self,
749 mount_point: &Path,
750 source_iso: &Path,
751 ) -> Result<Vec<BootEntry>, IsoError> {
752 let boot_dir = mount_point.join("boot");
753
754 if !self.env.exists(&boot_dir) {
755 return Ok(Vec::new());
756 }
757
758 let mut entries = Vec::new();
759
760 for entry in self.env.list_dir(&boot_dir)? {
762 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
763
764 if name.starts_with("vmlinuz") {
765 let kernel = entry.clone();
766 let mut initrd = boot_dir.join(format!(
767 "initrd.img{}",
768 name.strip_prefix("vmlinuz").unwrap_or("")
769 ));
770
771 if !self.env.exists(&initrd) {
773 initrd = boot_dir.join("initrd.img");
774 }
775 if !self.env.exists(&initrd) {
776 initrd = boot_dir.join(format!(
777 "initrd{}",
778 name.strip_prefix("vmlinuz").unwrap_or("")
779 ));
780 }
781
782 let has_initrd = self.env.exists(&initrd);
783
784 let rel_kernel = kernel
790 .strip_prefix(mount_point)
791 .map(std::path::Path::to_path_buf)
792 .map_err(|_| {
793 IsoError::Io(std::io::Error::new(
794 std::io::ErrorKind::InvalidData,
795 "Kernel path escape",
796 ))
797 })?;
798 let distribution = Distribution::from_paths(&rel_kernel);
799 let label = match distribution {
800 Distribution::Alpine => format!(
801 "Alpine {}",
802 name.strip_prefix("vmlinuz-").unwrap_or("").trim()
803 ),
804 Distribution::Arch => format!(
805 "Arch Linux {}",
806 name.strip_prefix("vmlinuz").unwrap_or("").trim()
807 ),
808 _ => format!(
809 "Linux {}",
810 name.strip_prefix("vmlinuz").unwrap_or("").trim()
811 ),
812 };
813 let kernel_args = if distribution == Distribution::Arch {
816 Some(
817 "archisobasedir=arch archiso_http_server=https://mirror.archlinux.org"
818 .to_string(),
819 )
820 } else {
821 None
822 };
823
824 entries.push(BootEntry {
825 label,
826 kernel: rel_kernel,
827 initrd: if has_initrd { Some(initrd) } else { None },
828 kernel_args,
829 distribution,
830 source_iso: source_iso
831 .file_name()
832 .and_then(|n| n.to_str())
833 .unwrap_or("unknown")
834 .to_string(),
835 pretty_name: None,
836 });
837 }
838 }
839
840 Ok(entries)
841 }
842
843 fn try_debian_layout(
845 &self,
846 mount_point: &Path,
847 source_iso: &Path,
848 ) -> Result<Vec<BootEntry>, IsoError> {
849 let mut entries = Vec::new();
850
851 let debian_markers = [
858 mount_point.join("install"),
859 mount_point.join("casper"),
860 mount_point.join(".disk"),
861 mount_point.join("pool"),
862 mount_point.join("dists"),
863 ];
864 if !debian_markers.iter().any(|p| self.env.exists(p)) {
865 return Ok(entries);
866 }
867
868 let search_paths = [
870 mount_point.join("install"),
871 mount_point.join("casper"),
872 mount_point.join("boot"),
873 ];
874
875 for search_dir in &search_paths {
876 if !self.env.exists(search_dir) {
877 continue;
878 }
879
880 for entry in self.env.list_dir(search_dir)? {
882 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
883
884 if name.starts_with("vmlinuz") {
885 let kernel = entry.clone();
886
887 let initrd_names = ["initrd.lz", "initrd.gz", "initrd.img", "initrd"];
889 let mut found_initrd = None;
890
891 for initrd_name in initrd_names {
892 let initrd_path = search_dir.join(initrd_name);
893 if self.env.exists(&initrd_path) {
894 found_initrd = Some(initrd_path);
895 break;
896 }
897 }
898
899 let kernel_args = if search_dir == &mount_point.join("casper") {
901 Some(
902 "boot=casper locale=en_US.UTF-8 keyboard-configuration/layoutcode=us"
903 .to_string(),
904 )
905 } else {
906 None
907 };
908
909 entries.push(BootEntry {
911 label: format!(
912 "Debian/Ubuntu {}",
913 name.strip_prefix("vmlinuz").unwrap_or("").trim()
914 ),
915 kernel: kernel
916 .strip_prefix(mount_point)
917 .map(std::path::Path::to_path_buf)
918 .map_err(|_| {
919 IsoError::Io(std::io::Error::new(
920 std::io::ErrorKind::InvalidData,
921 "Kernel path escape",
922 ))
923 })?,
924 initrd: found_initrd
925 .map(|p| {
926 p.strip_prefix(mount_point)
927 .map(std::path::Path::to_path_buf)
928 .map_err(|_| {
929 IsoError::Io(std::io::Error::new(
930 std::io::ErrorKind::InvalidData,
931 "Initrd path escape",
932 ))
933 })
934 })
935 .transpose()?,
936 kernel_args,
937 distribution: Distribution::Debian,
938 source_iso: source_iso
939 .file_name()
940 .and_then(|n| n.to_str())
941 .unwrap_or("unknown")
942 .to_string(),
943 pretty_name: None,
944 });
945 }
946 }
947 }
948
949 Ok(entries)
950 }
951
952 fn try_fedora_layout(
954 &self,
955 mount_point: &Path,
956 source_iso: &Path,
957 ) -> Result<Vec<BootEntry>, IsoError> {
958 let images_dir = mount_point.join("images").join("pxeboot");
959
960 if !self.env.exists(&images_dir) {
961 let alt_dir = mount_point.join("isolinux");
963 if !self.env.exists(&alt_dir) {
964 return Ok(Vec::new());
965 }
966 return self.process_fedora_isolinux(&alt_dir, mount_point, source_iso);
967 }
968
969 let mut entries = Vec::new();
970
971 for entry in self.env.list_dir(&images_dir)? {
973 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
974
975 if name.starts_with("vmlinuz") {
976 let kernel = entry.clone();
977
978 let version = name.strip_prefix("vmlinuz").unwrap_or("");
980 let initrd_names = [
981 format!("initrd{version}.img"),
982 "initrd.img".to_string(),
983 format!("initrd{}.img", version.trim_end_matches('-')),
984 ];
985
986 let mut found_initrd = None;
987 for initrd_name in &initrd_names {
988 let initrd_path = images_dir.join(initrd_name);
989 if self.env.exists(&initrd_path) {
990 found_initrd = Some(initrd_path);
991 break;
992 }
993 }
994
995 entries.push(BootEntry {
996 label: format!("Fedora {}", version.trim()),
997 kernel: kernel
998 .strip_prefix(mount_point)
999 .map(std::path::Path::to_path_buf)
1000 .map_err(|_| {
1001 IsoError::Io(std::io::Error::new(
1002 std::io::ErrorKind::InvalidData,
1003 "Kernel path escape",
1004 ))
1005 })?,
1006 initrd: found_initrd
1007 .map(|p| {
1008 p.strip_prefix(mount_point)
1009 .map(std::path::Path::to_path_buf)
1010 .map_err(|_| {
1011 IsoError::Io(std::io::Error::new(
1012 std::io::ErrorKind::InvalidData,
1013 "Initrd path escape",
1014 ))
1015 })
1016 })
1017 .transpose()?,
1018 kernel_args: Some("inst.stage2=hd:LABEL=Fedora-39-x86_64".to_string()),
1019 distribution: Distribution::Fedora,
1020 source_iso: source_iso
1021 .file_name()
1022 .and_then(|n| n.to_str())
1023 .unwrap_or("unknown")
1024 .to_string(),
1025 pretty_name: None,
1026 });
1027 }
1028 }
1029
1030 Ok(entries)
1031 }
1032
1033 fn process_fedora_isolinux(
1034 &self,
1035 isolinux_dir: &Path,
1036 mount_point: &Path,
1037 source_iso: &Path,
1038 ) -> Result<Vec<BootEntry>, IsoError> {
1039 let mut entries = Vec::new();
1040
1041 for entry in self.env.list_dir(isolinux_dir)? {
1042 let name = entry.file_name().and_then(|n| n.to_str()).unwrap_or("");
1043
1044 if name.starts_with("vmlinuz") {
1045 let kernel = entry.clone();
1046
1047 let images_dir = mount_point.join("images");
1049 let initrd_path = images_dir.join("initrd.img");
1050
1051 entries.push(BootEntry {
1052 label: format!(
1053 "Fedora (isolinux) {}",
1054 name.strip_prefix("vmlinuz").unwrap_or("").trim()
1055 ),
1056 kernel: kernel
1057 .strip_prefix(mount_point)
1058 .map(std::path::Path::to_path_buf)
1059 .map_err(|_| {
1060 IsoError::Io(std::io::Error::new(
1061 std::io::ErrorKind::InvalidData,
1062 "Kernel path escape",
1063 ))
1064 })?,
1065 initrd: if self.env.exists(&initrd_path) {
1066 Some(
1067 initrd_path
1068 .strip_prefix(mount_point)
1069 .map(std::path::Path::to_path_buf)
1070 .map_err(|_| {
1071 IsoError::Io(std::io::Error::new(
1072 std::io::ErrorKind::InvalidData,
1073 "Initrd path escape",
1074 ))
1075 })?,
1076 )
1077 } else {
1078 None
1079 },
1080 kernel_args: Some("inst.stage2=hd:LABEL=Fedora".to_string()),
1081 distribution: Distribution::Fedora,
1082 source_iso: source_iso
1083 .file_name()
1084 .and_then(|n| n.to_str())
1085 .unwrap_or("unknown")
1086 .to_string(),
1087 pretty_name: None,
1088 });
1089 }
1090 }
1091
1092 Ok(entries)
1093 }
1094
1095 #[allow(clippy::unnecessary_wraps)]
1123 fn try_windows_layout(
1124 &self,
1125 mount_point: &Path,
1126 source_iso: &Path,
1127 ) -> Result<Vec<BootEntry>, IsoError> {
1128 let bootmgr = mount_point.join("bootmgr");
1129 let boot_wim = mount_point.join("sources/boot.wim");
1130 let efi_ms_boot = mount_point.join("efi/microsoft/boot");
1131 let bootmgfw_efi = mount_point.join("efi/boot/bootx64.efi");
1132
1133 let has_any_marker = self.env.exists(&bootmgr)
1134 || self.env.exists(&boot_wim)
1135 || self.env.exists(&efi_ms_boot);
1136 if !has_any_marker {
1137 return Ok(Vec::new());
1138 }
1139
1140 let kernel_path = if self.env.exists(&bootmgr) {
1145 PathBuf::from("bootmgr")
1146 } else if self.env.exists(&bootmgfw_efi) {
1147 PathBuf::from("efi/boot/bootx64.efi")
1148 } else {
1149 PathBuf::from("sources/boot.wim")
1150 };
1151
1152 let label = "Windows (not kexec-bootable)".to_string();
1153
1154 Ok(vec![BootEntry {
1155 label,
1156 kernel: kernel_path,
1157 initrd: None,
1160 kernel_args: None,
1161 distribution: Distribution::Windows,
1162 source_iso: source_iso
1163 .file_name()
1164 .and_then(|n| n.to_str())
1165 .unwrap_or("unknown")
1166 .to_string(),
1167 pretty_name: None,
1168 }])
1169 }
1170}
1171
1172#[must_use]
1191pub fn read_pretty_name(mount_point: &Path) -> Option<String> {
1192 for rel in ["etc/os-release", "lib/os-release", "usr/lib/os-release"] {
1193 if let Some(name) = read_os_release(&mount_point.join(rel)) {
1194 return Some(name);
1195 }
1196 }
1197 if let Some(first_line) = read_first_nonempty_line(&mount_point.join(".disk/info")) {
1198 return Some(first_line);
1199 }
1200 if let Some(version) = read_first_nonempty_line(&mount_point.join("etc/alpine-release")) {
1201 return Some(format!("Alpine Linux {version}"));
1202 }
1203 None
1204}
1205
1206fn read_os_release(path: &Path) -> Option<String> {
1210 let content = std::fs::read_to_string(path).ok()?;
1211 parse_os_release_pretty_name(&content)
1212}
1213
1214#[must_use]
1217pub(crate) fn parse_os_release_pretty_name(content: &str) -> Option<String> {
1218 for line in content.lines() {
1219 let Some(rest) = line.strip_prefix("PRETTY_NAME=") else {
1220 continue;
1221 };
1222 let trimmed = rest
1225 .trim()
1226 .trim_matches(|c| c == '"' || c == '\'')
1227 .to_string();
1228 if trimmed.is_empty() {
1229 return None;
1230 }
1231 return Some(trimmed);
1232 }
1233 None
1234}
1235
1236fn read_first_nonempty_line(path: &Path) -> Option<String> {
1240 let content = std::fs::read_to_string(path).ok()?;
1241 for line in content.lines() {
1242 let trimmed = line.trim();
1243 if !trimmed.is_empty() {
1244 return Some(trimmed.to_string());
1245 }
1246 }
1247 None
1248}
1249
1250#[cfg(test)]
1251#[allow(
1252 clippy::unwrap_used,
1253 clippy::expect_used,
1254 clippy::too_many_lines,
1255 clippy::missing_panics_doc,
1256 clippy::match_same_arms
1257)]
1258mod tests {
1259 use super::*;
1260 use std::collections::HashMap;
1261 use std::sync::Mutex;
1262
1263 struct MockIsoEnvironment {
1265 files: HashMap<PathBuf, MockEntry>,
1266 mount_points: Mutex<Vec<PathBuf>>,
1267 }
1268
1269 #[derive(Debug, Clone)]
1270 enum MockEntry {
1271 File,
1272 Directory(Vec<PathBuf>),
1273 }
1274
1275 impl MockIsoEnvironment {
1276 fn new() -> Self {
1277 Self {
1278 files: HashMap::new(),
1279 mount_points: Mutex::new(Vec::new()),
1280 }
1281 }
1282
1283 fn with_iso(distribution: Distribution) -> Self {
1284 let mut env = Self::new();
1285
1286 let mount_base = PathBuf::from("/mock_mount");
1287
1288 match distribution {
1289 Distribution::Arch => {
1290 env.files.insert(
1292 mount_base.join("boot"),
1293 MockEntry::Directory(vec![
1294 mount_base.join("boot/vmlinuz"),
1295 mount_base.join("boot/initrd.img"),
1296 ]),
1297 );
1298 env.files
1299 .insert(mount_base.join("boot/vmlinuz"), MockEntry::File);
1300 env.files
1301 .insert(mount_base.join("boot/initrd.img"), MockEntry::File);
1302 }
1303 Distribution::Debian => {
1304 env.files.insert(
1306 mount_base.join("install"),
1307 MockEntry::Directory(vec![mount_base.join("install/vmlinuz")]),
1308 );
1309 env.files
1310 .insert(mount_base.join("install/vmlinuz"), MockEntry::File);
1311 env.files.insert(
1312 mount_base.join("casper"),
1313 MockEntry::Directory(vec![
1314 mount_base.join("casper/initrd.lz"),
1315 mount_base.join("casper/filesystem.squashfs"),
1316 ]),
1317 );
1318 env.files
1319 .insert(mount_base.join("casper/initrd.lz"), MockEntry::File);
1320 env.files.insert(
1321 mount_base.join("casper/filesystem.squashfs"),
1322 MockEntry::File,
1323 );
1324 }
1325 Distribution::Fedora => {
1326 env.files.insert(
1328 mount_base.join("images"),
1329 MockEntry::Directory(vec![mount_base.join("images/pxeboot")]),
1330 );
1331 env.files.insert(
1332 mount_base.join("images/pxeboot"),
1333 MockEntry::Directory(vec![
1334 mount_base.join("images/pxeboot/vmlinuz"),
1335 mount_base.join("images/pxeboot/initrd.img"),
1336 ]),
1337 );
1338 env.files
1339 .insert(mount_base.join("images/pxeboot/vmlinuz"), MockEntry::File);
1340 env.files.insert(
1341 mount_base.join("images/pxeboot/initrd.img"),
1342 MockEntry::File,
1343 );
1344 }
1345 Distribution::RedHat | Distribution::Alpine | Distribution::NixOS => {}
1350 Distribution::Windows => {
1351 env.files
1356 .insert(mount_base.join("bootmgr"), MockEntry::File);
1357 env.files.insert(
1358 mount_base.join("sources"),
1359 MockEntry::Directory(vec![mount_base.join("sources/boot.wim")]),
1360 );
1361 env.files
1362 .insert(mount_base.join("sources/boot.wim"), MockEntry::File);
1363 env.files.insert(
1364 mount_base.join("efi"),
1365 MockEntry::Directory(vec![mount_base.join("efi/microsoft")]),
1366 );
1367 env.files.insert(
1368 mount_base.join("efi/microsoft"),
1369 MockEntry::Directory(vec![mount_base.join("efi/microsoft/boot")]),
1370 );
1371 env.files.insert(
1372 mount_base.join("efi/microsoft/boot"),
1373 MockEntry::Directory(vec![]),
1374 );
1375 }
1376 Distribution::Unknown => {}
1377 }
1378
1379 env.files.insert(
1381 PathBuf::from("/isos"),
1382 MockEntry::Directory(vec![PathBuf::from("/isos/test.iso")]),
1383 );
1384 env.files
1385 .insert(PathBuf::from("/isos/test.iso"), MockEntry::File);
1386
1387 env
1388 }
1389 }
1390
1391 impl IsoEnvironment for MockIsoEnvironment {
1392 fn list_dir(&self, path: &std::path::Path) -> std::io::Result<Vec<PathBuf>> {
1393 match self.files.get(path) {
1394 Some(MockEntry::Directory(entries)) => Ok(entries.clone()),
1395 Some(MockEntry::File) => Err(std::io::Error::new(
1396 std::io::ErrorKind::NotFound,
1397 "Not a directory",
1398 )),
1399 None => Ok(Vec::new()), }
1401 }
1402
1403 fn exists(&self, path: &std::path::Path) -> bool {
1404 self.files.contains_key(path)
1405 }
1406
1407 fn metadata(&self, _path: &std::path::Path) -> std::io::Result<std::fs::Metadata> {
1408 Err(std::io::Error::new(
1422 std::io::ErrorKind::Unsupported,
1423 "MockIsoEnvironment::metadata is not implemented — see #138 for the design note",
1424 ))
1425 }
1426
1427 fn mount_iso(&self, iso_path: &std::path::Path) -> Result<PathBuf, IsoError> {
1428 let mount_point = PathBuf::from(format!(
1429 "/mock_mount/{}",
1430 iso_path
1431 .file_stem()
1432 .and_then(|s| s.to_str())
1433 .unwrap_or("iso")
1434 ));
1435
1436 self.mount_points
1443 .lock()
1444 .unwrap_or_else(std::sync::PoisonError::into_inner)
1445 .push(mount_point.clone());
1446 Ok(mount_point)
1447 }
1448
1449 fn unmount(&self, mount_point: &std::path::Path) -> Result<(), IsoError> {
1450 let mut points = self
1451 .mount_points
1452 .lock()
1453 .unwrap_or_else(std::sync::PoisonError::into_inner);
1454 points.retain(|p| p != mount_point);
1455 Ok(())
1456 }
1457 }
1458
1459 #[test]
1460 fn test_path_traversal_blocked() {
1461 let env = MockIsoEnvironment::new();
1462 let result = env.validate_path(
1463 PathBuf::from("/safe").as_path(),
1464 PathBuf::from("/safe/../../../etc/passwd").as_path(),
1465 );
1466
1467 assert!(result.is_err());
1468 match result {
1469 Err(IsoError::PathTraversal(_)) => {}
1470 _ => panic!("Expected PathTraversal error"),
1471 }
1472 }
1473
1474 #[test]
1475 fn test_path_allowed() {
1476 let env = MockIsoEnvironment::new();
1477 let result = env.validate_path(
1478 PathBuf::from("/safe").as_path(),
1479 PathBuf::from("/safe/subdir/file").as_path(),
1480 );
1481
1482 assert!(result.is_ok());
1483 }
1484
1485 #[test]
1486 fn test_path_outside_base_rejected() {
1487 let env = MockIsoEnvironment::new();
1491 let result = env.validate_path(
1492 PathBuf::from("/mnt/iso").as_path(),
1493 PathBuf::from("/etc/passwd").as_path(),
1494 );
1495 assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1496 }
1497
1498 #[test]
1499 fn test_path_sibling_of_base_rejected() {
1500 let env = MockIsoEnvironment::new();
1503 let result = env.validate_path(
1504 PathBuf::from("/safe").as_path(),
1505 PathBuf::from("/safe2/file").as_path(),
1506 );
1507 assert!(matches!(result, Err(IsoError::PathTraversal(_))));
1508 }
1509
1510 #[tokio::test]
1511 async fn test_arch_detection() {
1512 let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
1513 let parser = IsoParser::new(mock);
1514
1515 let mount_base = PathBuf::from("/mock_mount");
1516 let entries = parser
1517 .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1518 .await
1519 .unwrap();
1520
1521 assert!(!entries.is_empty());
1523 assert!(entries.iter().any(|e| e.distribution == Distribution::Arch));
1524 assert!(entries
1525 .iter()
1526 .any(|e| e.kernel.to_string_lossy().contains("vmlinuz")));
1527 }
1528
1529 #[tokio::test]
1530 async fn test_debian_detection() {
1531 let mock = MockIsoEnvironment::with_iso(Distribution::Debian);
1532 let parser = IsoParser::new(mock);
1533
1534 let mount_base = PathBuf::from("/mock_mount");
1535 let entries = parser
1536 .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1537 .await
1538 .unwrap();
1539
1540 assert!(!entries.is_empty());
1541 assert!(entries
1542 .iter()
1543 .any(|e| e.distribution == Distribution::Debian));
1544 }
1545
1546 #[tokio::test]
1547 async fn test_fedora_detection() {
1548 let mock = MockIsoEnvironment::with_iso(Distribution::Fedora);
1549 let parser = IsoParser::new(mock);
1550
1551 let mount_base = PathBuf::from("/mock_mount");
1552 let entries = parser
1553 .extract_boot_entries(&mount_base, &PathBuf::from("test.iso"))
1554 .await
1555 .unwrap();
1556
1557 assert!(!entries.is_empty());
1558 assert!(entries
1559 .iter()
1560 .any(|e| e.distribution == Distribution::Fedora));
1561 }
1562
1563 #[test]
1564 fn test_distribution_from_paths() {
1565 assert_eq!(
1566 Distribution::from_paths(PathBuf::from("/boot/vmlinuz").as_path()),
1567 Distribution::Arch
1568 );
1569 assert_eq!(
1570 Distribution::from_paths(PathBuf::from("/casper/vmlinuz").as_path()),
1571 Distribution::Debian
1572 );
1573 assert_eq!(
1574 Distribution::from_paths(PathBuf::from("/images/pxeboot/vmlinuz").as_path()),
1575 Distribution::Fedora
1576 );
1577 }
1578
1579 #[test]
1580 fn test_boot_entry_serialization() {
1581 let entry = BootEntry {
1582 label: "Test Linux".to_string(),
1583 kernel: PathBuf::from("boot/vmlinuz"),
1584 initrd: Some(PathBuf::from("boot/initrd.img")),
1585 kernel_args: Some("quiet".to_string()),
1586 distribution: Distribution::Arch,
1587 source_iso: "test.iso".to_string(),
1588 pretty_name: None,
1589 };
1590
1591 let json = serde_json::to_string(&entry).unwrap();
1592 let decoded: BootEntry = serde_json::from_str(&json).unwrap();
1593
1594 assert_eq!(decoded.label, "Test Linux");
1595 assert_eq!(decoded.distribution, Distribution::Arch);
1596 }
1597
1598 #[test]
1601 fn parse_pretty_name_systemd_shape() {
1602 let content = r#"
1603NAME="Ubuntu"
1604VERSION_ID="24.04"
1605PRETTY_NAME="Ubuntu 24.04.2 LTS (Noble Numbat)"
1606ID=ubuntu
1607"#;
1608 assert_eq!(
1609 parse_os_release_pretty_name(content).as_deref(),
1610 Some("Ubuntu 24.04.2 LTS (Noble Numbat)"),
1611 );
1612 }
1613
1614 #[test]
1615 fn parse_pretty_name_strips_single_quotes() {
1616 let content = "PRETTY_NAME='Alpine Linux v3.20'";
1617 assert_eq!(
1618 parse_os_release_pretty_name(content).as_deref(),
1619 Some("Alpine Linux v3.20"),
1620 );
1621 }
1622
1623 #[test]
1624 fn parse_pretty_name_unquoted_value() {
1625 let content = "PRETTY_NAME=Arch Linux";
1627 assert_eq!(
1628 parse_os_release_pretty_name(content).as_deref(),
1629 Some("Arch Linux"),
1630 );
1631 }
1632
1633 #[test]
1634 fn parse_pretty_name_empty_returns_none() {
1635 assert!(parse_os_release_pretty_name("PRETTY_NAME=\"\"").is_none());
1636 assert!(parse_os_release_pretty_name("").is_none());
1637 }
1638
1639 #[test]
1640 fn parse_pretty_name_missing_returns_none() {
1641 let content = "NAME=\"Ubuntu\"\nID=ubuntu";
1642 assert!(parse_os_release_pretty_name(content).is_none());
1643 }
1644
1645 #[test]
1646 fn parse_pretty_name_first_match_wins() {
1647 let content = "PRETTY_NAME=\"First\"\nPRETTY_NAME=\"Second\"";
1649 assert_eq!(
1650 parse_os_release_pretty_name(content).as_deref(),
1651 Some("First"),
1652 );
1653 }
1654
1655 #[test]
1656 fn read_pretty_name_finds_etc_os_release() {
1657 let tmp = tempfile::tempdir().unwrap();
1658 std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1659 std::fs::write(
1660 tmp.path().join("etc/os-release"),
1661 "PRETTY_NAME=\"Rocky Linux 9.3 (Blue Onyx)\"\n",
1662 )
1663 .unwrap();
1664 assert_eq!(
1665 read_pretty_name(tmp.path()).as_deref(),
1666 Some("Rocky Linux 9.3 (Blue Onyx)"),
1667 );
1668 }
1669
1670 #[test]
1671 fn read_pretty_name_falls_back_to_disk_info() {
1672 let tmp = tempfile::tempdir().unwrap();
1673 std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
1674 std::fs::write(
1675 tmp.path().join(".disk/info"),
1676 "Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)\n",
1677 )
1678 .unwrap();
1679 assert_eq!(
1680 read_pretty_name(tmp.path()).as_deref(),
1681 Some("Ubuntu 24.04.2 LTS \"Noble Numbat\" - Release amd64 (20250215)"),
1682 );
1683 }
1684
1685 #[test]
1686 fn read_pretty_name_alpine_release_prepends_alpine_linux() {
1687 let tmp = tempfile::tempdir().unwrap();
1688 std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1689 std::fs::write(tmp.path().join("etc/alpine-release"), "3.20.3\n").unwrap();
1690 assert_eq!(
1691 read_pretty_name(tmp.path()).as_deref(),
1692 Some("Alpine Linux 3.20.3"),
1693 );
1694 }
1695
1696 #[test]
1697 fn read_pretty_name_prefers_etc_over_lib() {
1698 let tmp = tempfile::tempdir().unwrap();
1699 std::fs::create_dir_all(tmp.path().join("etc")).unwrap();
1700 std::fs::create_dir_all(tmp.path().join("usr/lib")).unwrap();
1701 std::fs::write(
1702 tmp.path().join("etc/os-release"),
1703 "PRETTY_NAME=\"Etc Wins\"\n",
1704 )
1705 .unwrap();
1706 std::fs::write(
1707 tmp.path().join("usr/lib/os-release"),
1708 "PRETTY_NAME=\"Lib Loses\"\n",
1709 )
1710 .unwrap();
1711 assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Etc Wins"),);
1712 }
1713
1714 #[test]
1715 fn read_pretty_name_returns_none_for_empty_mount() {
1716 let tmp = tempfile::tempdir().unwrap();
1717 assert!(read_pretty_name(tmp.path()).is_none());
1718 }
1719
1720 #[test]
1721 fn read_pretty_name_skips_empty_disk_info_line() {
1722 let tmp = tempfile::tempdir().unwrap();
1723 std::fs::create_dir_all(tmp.path().join(".disk")).unwrap();
1724 std::fs::write(tmp.path().join(".disk/info"), "\n\n \nDebian 12.8\n").unwrap();
1725 assert_eq!(read_pretty_name(tmp.path()).as_deref(), Some("Debian 12.8"),);
1726 }
1727
1728 #[test]
1733 fn mock_metadata_fails_closed() {
1734 let env = MockIsoEnvironment::new();
1735 let err = env
1736 .metadata(std::path::Path::new("/mock_mount/boot/vmlinuz"))
1737 .expect_err("mock metadata() must surface an error");
1738 assert_eq!(err.kind(), std::io::ErrorKind::Unsupported);
1739 }
1740
1741 #[test]
1745 fn mock_mount_lock_recovers_from_poison() {
1746 use std::sync::Arc;
1747 let env = Arc::new(MockIsoEnvironment::new());
1748 let env_for_thread = env.clone();
1752 let join = std::thread::spawn(move || {
1753 let _guard = env_for_thread.mount_points.lock().unwrap();
1754 panic!("deliberately poisoning the mutex for this test");
1755 })
1756 .join();
1757 assert!(join.is_err(), "helper thread must have panicked");
1758
1759 let iso = std::path::Path::new("/isos/test.iso");
1762 let mount = env
1763 .mount_iso(iso)
1764 .expect("mount_iso must recover from poison");
1765 env.unmount(&mount)
1766 .expect("unmount must recover from poison");
1767 }
1768
1769 #[tokio::test]
1772 async fn extract_boot_entries_detects_windows_installer() {
1773 let mock = MockIsoEnvironment::with_iso(Distribution::Windows);
1774 let parser = IsoParser::new(mock);
1775
1776 let mount_base = PathBuf::from("/mock_mount");
1777 let entries = parser
1778 .extract_boot_entries(&mount_base, &PathBuf::from("Win11_25H2.iso"))
1779 .await
1780 .expect("Windows ISO should now produce a BootEntry instead of empty");
1781
1782 assert!(
1783 !entries.is_empty(),
1784 "Windows ISO must produce at least one entry"
1785 );
1786 let win = entries
1787 .iter()
1788 .find(|e| e.distribution == Distribution::Windows)
1789 .expect("one of the entries must be Distribution::Windows");
1790 assert_eq!(win.kernel.to_string_lossy(), "bootmgr");
1791 assert!(win.initrd.is_none());
1792 assert_eq!(win.kernel_args, None);
1793 assert!(win.label.contains("Windows"));
1794 assert!(win.source_iso.contains("Win11"));
1795 }
1796
1797 #[tokio::test]
1798 async fn try_windows_layout_declines_on_linux_layouts() {
1799 let mock = MockIsoEnvironment::with_iso(Distribution::Arch);
1802 let parser = IsoParser::new(mock);
1803
1804 let mount_base = PathBuf::from("/mock_mount");
1805 let entries = parser
1806 .extract_boot_entries(&mount_base, &PathBuf::from("arch.iso"))
1807 .await
1808 .expect("Arch ISO must produce entries");
1809
1810 assert!(
1812 !entries
1813 .iter()
1814 .any(|e| e.distribution == Distribution::Windows),
1815 "Windows detector must not fire on Arch fixture"
1816 );
1817 }
1818
1819 #[test]
1820 fn windows_boot_entry_has_not_kexec_bootable_quirk_in_iso_probe() {
1821 let iso_distro = Distribution::Windows;
1832 assert!(matches!(iso_distro, Distribution::Windows));
1833 }
1834}