1#![forbid(unsafe_code)]
2
3use crate::{
12 not_supported, ForensicFs, FsBlockRange, FsDirEntry, FsError, FsEventType, FsFileType,
13 FsMetadata, FsResult, FsTimelineEvent, FsTimestamp,
14};
15use iso9660_forensic::{rock_ridge, DirRecord, IsoReader};
16use std::collections::HashMap;
17use std::io::{Read, Seek};
18
19const ROOT_INO: u64 = 2;
21
22struct IsoNode {
24 #[allow(dead_code)]
25 parent: u64,
26 name: Vec<u8>,
27 is_dir: bool,
28 record: Option<DirRecord>,
30 children: Vec<u64>,
31}
32
33pub struct IsoForensicFs<R: Read + Seek> {
35 reader: IsoReader<R>,
36 nodes: HashMap<u64, IsoNode>,
37}
38
39impl<R: Read + Seek> IsoForensicFs<R> {
40 pub fn new(source: R) -> Result<Self, FsError> {
41 let mut reader =
42 IsoReader::open(source).map_err(|e| FsError::Corrupt(format!("not an ISO: {e}")))?;
43
44 let entries = reader
45 .walk()
46 .map_err(|e| FsError::Corrupt(format!("walk failed: {e}")))?;
47
48 let mut nodes: HashMap<u64, IsoNode> = HashMap::new();
49 nodes.insert(
50 ROOT_INO,
51 IsoNode {
52 parent: ROOT_INO,
53 name: b"/".to_vec(),
54 is_dir: true,
55 record: None,
56 children: vec![],
57 },
58 );
59
60 let mut path_ino: HashMap<String, u64> = HashMap::new();
61 path_ino.insert(String::new(), ROOT_INO);
62
63 for (i, e) in entries.iter().enumerate() {
64 let ino = 3 + i as u64;
65 let name = e
66 .path
67 .rsplit('/')
68 .next()
69 .unwrap_or(&e.path)
70 .as_bytes()
71 .to_vec();
72 let parent_path = match e.path.rsplit_once('/') {
73 Some((p, _)) => p.to_string(),
74 None => String::new(),
75 };
76 let parent = path_ino.get(&parent_path).copied().unwrap_or(ROOT_INO);
77
78 path_ino.insert(e.path.clone(), ino);
79 nodes.insert(
80 ino,
81 IsoNode {
82 parent,
83 name,
84 is_dir: e.record.is_dir(),
85 record: Some(e.record.clone()),
86 children: vec![],
87 },
88 );
89 if let Some(p) = nodes.get_mut(&parent) {
90 p.children.push(ino);
91 }
92 }
93
94 Ok(Self { reader, nodes })
95 }
96
97 fn node(&self, ino: u64) -> FsResult<&IsoNode> {
98 self.nodes
99 .get(&ino)
100 .ok_or_else(|| FsError::NotFound(format!("inode {ino}")))
101 }
102
103 fn file_type_of(node: &IsoNode) -> FsFileType {
105 if node.is_dir {
106 return FsFileType::Directory;
107 }
108 if let Some(rec) = &node.record {
109 if rock_ridge::symlink_target(&rec.system_use).is_some() {
110 return FsFileType::Symlink;
111 }
112 }
113 FsFileType::RegularFile
114 }
115}
116
117impl<R: Read + Seek> ForensicFs for IsoForensicFs<R> {
118 fn root_ino(&self) -> u64 {
119 ROOT_INO
120 }
121
122 fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
123 let node = self.node(ino)?;
124 let mut out = Vec::with_capacity(node.children.len());
125 for &child in &node.children {
126 if let Some(c) = self.nodes.get(&child) {
127 out.push(FsDirEntry {
128 inode: child,
129 name: c.name.clone(),
130 file_type: Self::file_type_of(c),
131 });
132 }
133 }
134 Ok(out)
135 }
136
137 fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
138 let node = self.node(parent_ino)?;
139 for &child in &node.children {
140 if let Some(c) = self.nodes.get(&child) {
141 if c.name == name {
142 return Ok(Some(child));
143 }
144 }
145 }
146 Ok(None)
147 }
148
149 fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
150 let node = self.node(ino)?;
151 let file_type = Self::file_type_of(node);
152 let size = node.record.as_ref().map_or(0, |r| u64::from(r.size));
153
154 let px = node
156 .record
157 .as_ref()
158 .and_then(|r| rock_ridge::posix_attrs(&r.system_use));
159 let (mode, uid, gid, nlink) = if let Some(p) = px {
160 (
161 (p.mode & 0o7777) as u16,
162 p.uid,
163 p.gid,
164 p.nlink.min(u32::from(u16::MAX)) as u16,
165 )
166 } else {
167 let m = if node.is_dir { 0o555 } else { 0o444 };
168 (m, 0, 0, 1)
169 };
170
171 let tf = node
173 .record
174 .as_ref()
175 .and_then(|r| rock_ridge::timestamps(&r.system_use));
176 let mtime = tf
177 .as_ref()
178 .and_then(|t| t.modify)
179 .map(short_ts_to_unix)
180 .unwrap_or_default();
181 let atime = tf
182 .as_ref()
183 .and_then(|t| t.access)
184 .map_or(mtime, short_ts_to_unix);
185 let ctime = tf
186 .as_ref()
187 .and_then(|t| t.attributes)
188 .map_or(mtime, short_ts_to_unix);
189 let crtime = tf
190 .as_ref()
191 .and_then(|t| t.creation)
192 .map_or(mtime, short_ts_to_unix);
193
194 Ok(FsMetadata {
195 ino,
196 file_type,
197 mode,
198 uid,
199 gid,
200 size,
201 links_count: nlink,
202 atime,
203 mtime,
204 ctime,
205 crtime,
206 allocated: true,
207 })
208 }
209
210 fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
211 let record = self
212 .node(ino)?
213 .record
214 .clone()
215 .ok_or_else(|| FsError::NotFound(format!("inode {ino} has no data")))?;
216 if record.is_dir() {
217 return Err(not_supported("read_file on a directory"));
218 }
219 self.reader
220 .read_file_entry(&record)
221 .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))
222 }
223
224 fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
225 let data = self.read_file(ino)?;
226 let start = (offset as usize).min(data.len());
227 let end = start.saturating_add(len as usize).min(data.len());
228 Ok(data[start..end].to_vec())
229 }
230
231 fn read_link(&mut self, ino: u64) -> FsResult<Vec<u8>> {
232 let record = self
233 .node(ino)?
234 .record
235 .clone()
236 .ok_or_else(|| not_supported("read_link on root"))?;
237 rock_ridge::symlink_target(&record.system_use)
238 .map(String::into_bytes)
239 .ok_or_else(|| not_supported("not a symlink"))
240 }
241
242 fn timeline(&mut self) -> FsResult<Vec<FsTimelineEvent>> {
243 let tl = self
244 .reader
245 .timeline()
246 .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))?;
247 let mut path_ino: HashMap<&[u8], u64> = HashMap::new();
249 for (ino, node) in &self.nodes {
250 path_ino.insert(node.name.as_slice(), *ino);
251 }
252 let mut out = Vec::new();
253 for e in tl {
254 let ts = e.modify_ts.map(short_ts_to_unix).unwrap_or_default();
255 let base = e.path.rsplit('/').next().unwrap_or(&e.path).as_bytes();
256 let ino = path_ino.get(base).copied().unwrap_or(0);
257 out.push(FsTimelineEvent {
258 timestamp: ts,
259 event_type: FsEventType::Modified,
260 inode: ino,
261 size: u64::from(e.size),
262 uid: 0,
263 gid: 0,
264 });
265 }
266 Ok(out)
267 }
268
269 fn unallocated_blocks(&mut self) -> FsResult<Vec<FsBlockRange>> {
270 let gaps = self
271 .reader
272 .audit_sector_gaps()
273 .map_err(|e| FsError::Io(std::io::Error::other(e.to_string())))?;
274 Ok(gaps
275 .into_iter()
276 .filter(|g| g.nonzero)
277 .map(|g| FsBlockRange {
278 start: u64::from(g.lba),
279 length: 1,
280 })
281 .collect())
282 }
283
284 fn fs_info(&self) -> FsResult<serde_json::Value> {
285 Ok(serde_json::json!({
286 "type": "iso9660",
287 "volume_label": self.reader.volume_label(),
288 "system_id": self.reader.system_id(),
289 "application_id": self.reader.application_id(),
290 "data_preparer": self.reader.data_preparer_id(),
291 "volume_space_size": self.reader.volume_space_size(),
292 "rock_ridge": self.reader.has_rock_ridge(),
293 "joliet": self.reader.has_joliet(),
294 "udf": self.reader.has_udf(),
295 "sessions": self.reader.session_count(),
296 }))
297 }
298
299 fn block_size(&self) -> u64 {
300 2048
301 }
302}
303
304fn short_ts_to_unix(t: [u8; 7]) -> FsTimestamp {
308 let year = 1900_i64 + i64::from(t[0]);
309 let secs = civil_to_unix(
310 year,
311 i64::from(t[1]),
312 i64::from(t[2]),
313 i64::from(t[3]),
314 i64::from(t[4]),
315 i64::from(t[5]),
316 );
317 let tz = i64::from(t[6] as i8) * 15 * 60;
319 FsTimestamp {
320 seconds: secs - tz,
321 nanoseconds: 0,
322 }
323}
324
325fn civil_to_unix(y: i64, m: i64, d: i64, hh: i64, mm: i64, ss: i64) -> i64 {
327 let y = if m <= 2 { y - 1 } else { y };
328 let era = if y >= 0 { y } else { y - 399 } / 400;
329 let yoe = y - era * 400;
330 let mp = (m + 9) % 12;
331 let doy = (153 * mp + 2) / 5 + d - 1;
332 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
333 let days = era * 146_097 + doe - 719_468;
334 days * 86_400 + hh * 3_600 + mm * 60 + ss
335}
336
337#[cfg(test)]
338mod tests {
339 use super::*;
340 use std::io::Cursor;
341
342 const ISO: &str = "/Users/4n6h4x0r/src/iso9660-forensic/iso/tests/data/rock_ridge.iso";
343
344 fn open() -> Option<IsoForensicFs<Cursor<Vec<u8>>>> {
345 let data = std::fs::read(ISO).ok()?;
346 IsoForensicFs::new(Cursor::new(data)).ok()
347 }
348
349 #[test]
350 fn root_ino_is_2() {
351 let Some(fs) = open() else {
352 eprintln!("skip");
353 return;
354 };
355 assert_eq!(fs.root_ino(), 2);
356 }
357
358 #[test]
359 fn read_dir_root_has_entries() {
360 let Some(mut fs) = open() else {
361 eprintln!("skip");
362 return;
363 };
364 let entries = fs.read_dir(2).unwrap();
365 let names: Vec<String> = entries.iter().map(FsDirEntry::name_str).collect();
366 assert!(names.contains(&"hello.txt".to_string()), "got: {names:?}");
367 }
368
369 #[test]
370 fn lookup_finds_file() {
371 let Some(mut fs) = open() else {
372 eprintln!("skip");
373 return;
374 };
375 let ino = fs.lookup(2, b"hello.txt").unwrap();
376 assert!(ino.is_some(), "hello.txt must be found under root");
377 }
378
379 #[test]
380 fn metadata_root_is_directory() {
381 let Some(mut fs) = open() else {
382 eprintln!("skip");
383 return;
384 };
385 let meta = fs.metadata(2).unwrap();
386 assert_eq!(meta.file_type, FsFileType::Directory);
387 assert!(meta.allocated);
388 }
389
390 #[test]
391 fn read_file_returns_content() {
392 let Some(mut fs) = open() else {
393 eprintln!("skip");
394 return;
395 };
396 let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
397 let data = fs.read_file(ino).unwrap();
398 assert!(
399 String::from_utf8_lossy(&data).contains("hello from iso corpus"),
400 "got: {:?}",
401 String::from_utf8_lossy(&data)
402 );
403 }
404
405 #[test]
406 fn read_file_range_returns_prefix() {
407 let Some(mut fs) = open() else {
408 eprintln!("skip");
409 return;
410 };
411 let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
412 let data = fs.read_file_range(ino, 0, 5).unwrap();
413 assert_eq!(&data, b"hello");
414 }
415
416 #[test]
417 fn metadata_file_is_regular() {
418 let Some(mut fs) = open() else {
419 eprintln!("skip");
420 return;
421 };
422 let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
423 let meta = fs.metadata(ino).unwrap();
424 assert_eq!(meta.file_type, FsFileType::RegularFile);
425 assert_eq!(meta.size, 22); }
427
428 #[test]
429 fn lookup_subdir_then_file() {
430 let Some(mut fs) = open() else {
431 eprintln!("skip");
432 return;
433 };
434 let sub = fs.lookup(2, b"subdir").unwrap();
435 assert!(sub.is_some(), "subdir must be found");
436 let sub = sub.unwrap();
437 let deep = fs.lookup(sub, b"deep.txt").unwrap();
438 assert!(deep.is_some(), "subdir/deep.txt must be found");
439 }
440
441 #[test]
442 fn block_size_is_2048() {
443 let Some(fs) = open() else {
444 eprintln!("skip");
445 return;
446 };
447 assert_eq!(fs.block_size(), 2048);
448 }
449
450 #[test]
451 fn timeline_has_events() {
452 let Some(mut fs) = open() else {
453 eprintln!("skip");
454 return;
455 };
456 let tl = fs.timeline().unwrap();
457 assert!(
458 !tl.is_empty(),
459 "Rock Ridge ISO should yield timeline events"
460 );
461 }
462
463 #[test]
464 fn fs_info_reports_iso() {
465 let Some(fs) = open() else {
466 eprintln!("skip");
467 return;
468 };
469 let info = fs.fs_info().unwrap();
470 assert_eq!(info["type"], "iso9660");
471 assert_eq!(info["rock_ridge"], true);
472 }
473
474 #[test]
475 fn civil_to_unix_epoch() {
476 assert_eq!(civil_to_unix(1970, 1, 1, 0, 0, 0), 0);
478 assert_eq!(civil_to_unix(2000, 1, 1, 0, 0, 0), 946_684_800);
480 }
481}