Skip to main content

bootsmith_iso/
lib.rs

1//! ISO9660 inspection. Two responsibilities:
2//!
3//! 1. **Classify** an ISO into one of the four `BootMode` families so the
4//!    pipeline knows what to build (auto-mode resolution).
5//! 2. **Inspect** an ISO well enough to dry-run the file copy step without
6//!    actually mounting it via `hdiutil`.
7//!
8//! The classifier walks the ISO9660 directory tree and applies the current
9//! mode markers:
10//!
11//! - Hybrid: protective MBR / GPT signature at offset 0x1FE + EFI System
12//!   Partition GUID present in protective MBR area.
13//! - Windows NT5: contains `I386/TXTSETUP.SIF` plus NT5 loader/marker files.
14//! - Windows: contains `bootmgr` AND `sources/install.wim` or
15//!   `sources/install.esd`.
16//! - IsolinuxLinux: contains `isolinux/isolinux.bin`.
17//! - UefiOnly: contains `EFI/BOOT/BOOTX64.EFI` (or other UEFI loader path)
18//!   AND lacks an MBR signature.
19//!
20//! Windows 2000 media (NT 5.0) is split out from XP/2003 (NT 5.1/5.2)
21//! using the WIN51/WIN52 root-marker files that Microsoft has shipped on
22//! XP-and-later install media since XP launched. Presence = XP/2003 →
23//! `WindowsNtXp`; absence (but still NT5-class) = Win2k →
24//! `Windows2000`. The two modes share the GRUB4DOS chain shape but
25//! differ in the textmode ramdisk driver (FiraDisk vs SVBus).
26//!
27//! Vista (and a handful of other Microsoft install DVDs) ship a stub
28//! ISO9660 whose PVD root contains only `README.TXT`; the real install
29//! tree lives in UDF. The [`udf`] module walks that tree so the classifier
30//! still sees `BOOTMGR` and `SOURCES/INSTALL.WIM` on those discs.
31
32mod udf;
33
34use std::collections::BTreeSet;
35use std::fs::File;
36use std::io::{Read, Seek, SeekFrom};
37use std::path::Path;
38use thiserror::Error;
39use bootsmith_core::plan::BootMode;
40
41const SECTOR_SIZE: u64 = 2048;
42const PVD_SECTOR: u64 = 16;
43const ISO_ID: &[u8; 5] = b"CD001";
44const MAX_DIR_DEPTH: usize = 8;
45
46#[derive(Debug, Error)]
47pub enum IsoError {
48    #[error("I/O: {0}")]
49    Io(#[from] std::io::Error),
50
51    #[error("not a valid ISO9660 image: {0}")]
52    NotIso9660(String),
53
54    #[error("cannot determine boot mode automatically; pass --type explicitly")]
55    Ambiguous,
56}
57
58pub type Result<T> = std::result::Result<T, IsoError>;
59
60/// Inspect an ISO and return the boot mode that should be used.
61pub fn classify(path: &Path) -> Result<BootMode> {
62    let mut iso = IsoReader::open(path)?;
63    let mut entries = iso.collect_paths()?;
64    if let Some(udf_entries) = udf::collect_paths(path) {
65        entries.extend(udf_entries);
66    }
67
68    if is_nt5_install_media(&entries) {
69        return Ok(if has_win51_or_win52_marker(&entries) {
70            BootMode::WindowsNtXp
71        } else {
72            BootMode::Windows2000
73        });
74    }
75    if has(&entries, "BOOTMGR")
76        && (has(&entries, "SOURCES/INSTALL.WIM") || has(&entries, "SOURCES/INSTALL.ESD"))
77    {
78        return Ok(BootMode::Windows);
79    }
80    if has(&entries, "ISOLINUX/ISOLINUX.BIN") {
81        return Ok(BootMode::IsolinuxLinux);
82    }
83    if has(&entries, "EFI/BOOT/BOOTX64.EFI") {
84        return Ok(BootMode::UefiOnly);
85    }
86
87    Err(IsoError::Ambiguous)
88}
89
90fn is_nt5_install_media(entries: &BTreeSet<String>) -> bool {
91    has(entries, "I386/TXTSETUP.SIF")
92        && (has(entries, "I386/SETUPLDR.BIN")
93            || has(entries, "I386/NTDETECT.COM")
94            || has_win51_or_win52_marker(entries))
95        && !has(entries, "BOOTMGR")
96        && !entries.iter().any(|p| p.starts_with("SOURCES/"))
97}
98
99fn has_win51_or_win52_marker(entries: &BTreeSet<String>) -> bool {
100    entries.iter().any(|p| {
101        p == "WIN51" || p.starts_with("WIN51") || p == "WIN52" || p.starts_with("WIN52")
102    })
103}
104
105fn has(entries: &BTreeSet<String>, path: &str) -> bool {
106    entries.contains(path)
107}
108
109#[derive(Debug, Clone, Copy)]
110struct DirRef {
111    extent: u32,
112    len: u32,
113}
114
115struct IsoReader {
116    file: File,
117    root: DirRef,
118}
119
120impl IsoReader {
121    fn open(path: &Path) -> Result<Self> {
122        let mut file = File::open(path)?;
123        let mut pvd = [0u8; SECTOR_SIZE as usize];
124        file.seek(SeekFrom::Start(PVD_SECTOR * SECTOR_SIZE))?;
125        file.read_exact(&mut pvd)?;
126
127        if pvd[0] != 1 || &pvd[1..6] != ISO_ID {
128            return Err(IsoError::NotIso9660(
129                "missing primary volume descriptor".into(),
130            ));
131        }
132
133        let root = parse_dir_record(&pvd[156..])
134            .ok_or_else(|| IsoError::NotIso9660("missing root directory record".into()))?
135            .dir_ref;
136
137        Ok(Self { file, root })
138    }
139
140    fn collect_paths(&mut self) -> Result<BTreeSet<String>> {
141        let mut out = BTreeSet::new();
142        self.walk_dir(self.root, "", 0, &mut out)?;
143        Ok(out)
144    }
145
146    fn walk_dir(
147        &mut self,
148        dir: DirRef,
149        prefix: &str,
150        depth: usize,
151        out: &mut BTreeSet<String>,
152    ) -> Result<()> {
153        if depth > MAX_DIR_DEPTH {
154            return Ok(());
155        }
156
157        let bytes = self.read_extent(dir)?;
158        let mut offset = 0usize;
159        while offset < bytes.len() {
160            let len = bytes[offset] as usize;
161            if len == 0 {
162                offset = ((offset / SECTOR_SIZE as usize) + 1) * SECTOR_SIZE as usize;
163                continue;
164            }
165            if offset + len > bytes.len() {
166                break;
167            }
168
169            if let Some(record) = parse_dir_record(&bytes[offset..offset + len]) {
170                if record.name != "\0" && record.name != "\u{1}" {
171                    let path = if prefix.is_empty() {
172                        record.name.clone()
173                    } else {
174                        format!("{prefix}/{}", record.name)
175                    };
176                    out.insert(path.clone());
177                    if record.is_dir {
178                        self.walk_dir(record.dir_ref, &path, depth + 1, out)?;
179                    }
180                }
181            }
182            offset += len;
183        }
184        Ok(())
185    }
186
187    fn read_extent(&mut self, dir: DirRef) -> Result<Vec<u8>> {
188        let mut buf = vec![0u8; dir.len as usize];
189        self.file
190            .seek(SeekFrom::Start(dir.extent as u64 * SECTOR_SIZE))?;
191        self.file.read_exact(&mut buf)?;
192        Ok(buf)
193    }
194}
195
196struct DirRecord {
197    dir_ref: DirRef,
198    is_dir: bool,
199    name: String,
200}
201
202fn parse_dir_record(bytes: &[u8]) -> Option<DirRecord> {
203    if bytes.len() < 34 || bytes[0] as usize > bytes.len() {
204        return None;
205    }
206    let name_len = bytes[32] as usize;
207    if 33 + name_len > bytes.len() {
208        return None;
209    }
210    let extent = u32::from_le_bytes(bytes[2..6].try_into().ok()?);
211    let len = u32::from_le_bytes(bytes[10..14].try_into().ok()?);
212    let is_dir = bytes[25] & 0x02 != 0;
213    let raw_name = &bytes[33..33 + name_len];
214    let name = normalize_iso_name(raw_name);
215    Some(DirRecord {
216        dir_ref: DirRef { extent, len },
217        is_dir,
218        name,
219    })
220}
221
222fn normalize_iso_name(raw: &[u8]) -> String {
223    if raw == [0] {
224        return "\0".into();
225    }
226    if raw == [1] {
227        return "\u{1}".into();
228    }
229    let mut s = String::from_utf8_lossy(raw).to_uppercase();
230    if let Some((base, _version)) = s.split_once(';') {
231        s = base.to_string();
232    }
233    s.trim_end_matches('.').to_string()
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::fs;
240    use std::io::Write;
241
242    #[test]
243    fn classifies_nt5_xp_media() {
244        let iso = synthetic_iso(&[
245            ("I386", true),
246            ("I386/TXTSETUP.SIF", false),
247            ("I386/SETUPLDR.BIN", false),
248            ("I386/NTDETECT.COM", false),
249            ("WIN51IP", false),
250        ]);
251        let path = write_temp_iso("xp", &iso);
252        assert_eq!(classify(&path).unwrap(), BootMode::WindowsNtXp);
253        let _ = fs::remove_file(path);
254    }
255
256    #[test]
257    fn classifies_nt5_windows_2000_media_without_win51_marker() {
258        let iso = synthetic_iso(&[
259            ("I386", true),
260            ("I386/TXTSETUP.SIF", false),
261            ("I386/SETUPLDR.BIN", false),
262            ("I386/NTDETECT.COM", false),
263        ]);
264        let path = write_temp_iso("win2000", &iso);
265        assert_eq!(classify(&path).unwrap(), BootMode::Windows2000);
266        let _ = fs::remove_file(path);
267    }
268
269    #[test]
270    fn classifies_nt5_windows_2003_media_with_win52_marker_as_ntxp() {
271        let iso = synthetic_iso(&[
272            ("I386", true),
273            ("I386/TXTSETUP.SIF", false),
274            ("I386/SETUPLDR.BIN", false),
275            ("I386/NTDETECT.COM", false),
276            ("WIN52", false),
277        ]);
278        let path = write_temp_iso("win2003", &iso);
279        assert_eq!(classify(&path).unwrap(), BootMode::WindowsNtXp);
280        let _ = fs::remove_file(path);
281    }
282
283    #[test]
284    fn classifies_windows_nt6_media() {
285        let iso = synthetic_iso(&[
286            ("BOOTMGR", false),
287            ("SOURCES", true),
288            ("SOURCES/INSTALL.WIM", false),
289        ]);
290        let path = write_temp_iso("win", &iso);
291        assert_eq!(classify(&path).unwrap(), BootMode::Windows);
292        let _ = fs::remove_file(path);
293    }
294
295    #[test]
296    fn ambiguous_when_markers_missing() {
297        let iso = synthetic_iso(&[("README.TXT", false)]);
298        let path = write_temp_iso("ambiguous", &iso);
299        assert!(matches!(classify(&path), Err(IsoError::Ambiguous)));
300        let _ = fs::remove_file(path);
301    }
302
303    fn write_temp_iso(name: &str, bytes: &[u8]) -> std::path::PathBuf {
304        let path = std::env::temp_dir().join(format!(
305            "bootsmith_iso_{name}_{}_{}.iso",
306            std::process::id(),
307            bytes.len()
308        ));
309        let mut file = File::create(&path).unwrap();
310        file.write_all(bytes).unwrap();
311        path
312    }
313
314    fn synthetic_iso(entries: &[(&str, bool)]) -> Vec<u8> {
315        let mut iso = vec![0u8; 24 * SECTOR_SIZE as usize];
316        let root_sector = 20u32;
317        let mut next_sector = 21u32;
318
319        let mut dirs = BTreeSet::new();
320        dirs.insert(String::new());
321        for (path, is_dir) in entries {
322            let parts: Vec<_> = path.split('/').collect();
323            if parts.len() > 1 {
324                dirs.insert(parts[..parts.len() - 1].join("/"));
325            }
326            if *is_dir {
327                dirs.insert((*path).to_string());
328            }
329        }
330
331        let mut dir_refs = std::collections::BTreeMap::new();
332        dir_refs.insert(String::new(), DirRef { extent: root_sector, len: SECTOR_SIZE as u32 });
333        for dir in dirs.iter().filter(|d| !d.is_empty()) {
334            dir_refs.insert(dir.clone(), DirRef { extent: next_sector, len: SECTOR_SIZE as u32 });
335            next_sector += 1;
336        }
337
338        let needed = (next_sector as usize + 1) * SECTOR_SIZE as usize;
339        if iso.len() < needed {
340            iso.resize(needed, 0);
341        }
342
343        write_pvd(&mut iso, dir_refs[""]);
344
345        for dir in &dirs {
346            let dir_ref = dir_refs[dir];
347            let mut records = Vec::new();
348            records.extend(dir_record("\0", dir_ref, true));
349            records.extend(dir_record("\u{1}", dir_ref, true));
350
351            for (path, is_dir) in entries {
352                let parent = parent_dir(path);
353                if parent == *dir {
354                    let name = path.rsplit('/').next().unwrap();
355                    let child_ref = if *is_dir {
356                        dir_refs[*path]
357                    } else {
358                        DirRef { extent: next_sector, len: 0 }
359                    };
360                    records.extend(dir_record(name, child_ref, *is_dir));
361                }
362            }
363
364            let start = dir_ref.extent as usize * SECTOR_SIZE as usize;
365            iso[start..start + records.len()].copy_from_slice(&records);
366        }
367
368        iso
369    }
370
371    fn parent_dir(path: &str) -> String {
372        path.rsplit_once('/').map(|(p, _)| p.to_string()).unwrap_or_default()
373    }
374
375    fn write_pvd(iso: &mut [u8], root: DirRef) {
376        let start = PVD_SECTOR as usize * SECTOR_SIZE as usize;
377        iso[start] = 1;
378        iso[start + 1..start + 6].copy_from_slice(ISO_ID);
379        iso[start + 6] = 1;
380        let rec = dir_record("\0", root, true);
381        iso[start + 156..start + 156 + rec.len()].copy_from_slice(&rec);
382    }
383
384    fn dir_record(name: &str, dir_ref: DirRef, is_dir: bool) -> Vec<u8> {
385        let name_bytes: Vec<u8> = match name {
386            "\0" => vec![0],
387            "\u{1}" => vec![1],
388            other => other.as_bytes().to_vec(),
389        };
390        let mut len = 33 + name_bytes.len();
391        if len % 2 != 0 {
392            len += 1;
393        }
394        let mut rec = vec![0u8; len];
395        rec[0] = len as u8;
396        rec[2..6].copy_from_slice(&dir_ref.extent.to_le_bytes());
397        rec[10..14].copy_from_slice(&dir_ref.len.to_le_bytes());
398        rec[25] = if is_dir { 0x02 } else { 0x00 };
399        rec[28..30].copy_from_slice(&1u16.to_le_bytes());
400        rec[32] = name_bytes.len() as u8;
401        rec[33..33 + name_bytes.len()].copy_from_slice(&name_bytes);
402        rec
403    }
404}