Skip to main content

forensic_mount/
fs_iso.rs

1#![forbid(unsafe_code)]
2
3//! ISO 9660 / Rock Ridge / Joliet filesystem support via the
4//! `iso9660-forensic` crate.  Enabled with the `iso` feature flag.
5//!
6//! ISO 9660 has no native inode numbers, so synthetic inodes are assigned by
7//! walking the directory tree once at open time (root = 2, entries from 3 up).
8//! ISO is a read-only optical format: there are no deleted inodes, no journal,
9//! and no writable overlay.
10
11use 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
19/// Root synthetic inode (mirrors ext4's convention of root = 2).
20const ROOT_INO: u64 = 2;
21
22/// One node in the synthetic inode table.
23struct IsoNode {
24    #[allow(dead_code)]
25    parent: u64,
26    name: Vec<u8>,
27    is_dir: bool,
28    /// `None` for the synthetic root (which has no on-disc directory record).
29    record: Option<DirRecord>,
30    children: Vec<u64>,
31}
32
33/// `ForensicFs` implementation for ISO 9660 images.
34pub 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    /// Classify a node into a filesystem file type.
104    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        // Rock Ridge POSIX attributes, if present.
155        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        // Rock Ridge timestamps (short form), if present.
172        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        // Build a path -> inode map for cross-referencing.
248        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
304/// Convert a 7-byte Rock Ridge short timestamp to a Unix `FsTimestamp`.
305///
306/// Layout: `[year-1900, month, day, hour, min, sec, tz_offset_15min(i8)]`.
307fn 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    // tz offset is signed, in 15-minute units; local = utc + offset.
318    let tz = i64::from(t[6] as i8) * 15 * 60;
319    FsTimestamp {
320        seconds: secs - tz,
321        nanoseconds: 0,
322    }
323}
324
325/// Days/seconds from the Unix epoch for a civil date (Howard Hinnant's algorithm).
326fn 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); // "hello from iso corpus\n"
426    }
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        // 1970-01-01T00:00:00 -> 0
477        assert_eq!(civil_to_unix(1970, 1, 1, 0, 0, 0), 0);
478        // 2000-01-01T00:00:00 -> 946684800
479        assert_eq!(civil_to_unix(2000, 1, 1, 0, 0, 0), 946_684_800);
480    }
481}