1mod 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
60pub 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}