Skip to main content

ext4fs/
dir.rs

1#![forbid(unsafe_code)]
2use crate::error::{Ext4Error, Result};
3use crate::inode::InodeReader;
4use crate::ondisk::{parse_dir_block, DirEntry, FileType};
5use std::io::{Read, Seek};
6
7const MAX_SYMLINK_DEPTH: u32 = 40;
8
9pub struct DirReader<R: Read + Seek> {
10    inode_reader: InodeReader<R>,
11}
12
13impl<R: Read + Seek> DirReader<R> {
14    /// Wrap an `InodeReader` to provide directory-level access.
15    pub fn new(inode_reader: InodeReader<R>) -> Self {
16        Self { inode_reader }
17    }
18
19    /// Borrow the underlying `InodeReader`.
20    pub fn inode_reader(&self) -> &InodeReader<R> {
21        &self.inode_reader
22    }
23
24    /// Mutably borrow the underlying `InodeReader`.
25    pub fn inode_reader_mut(&mut self) -> &mut InodeReader<R> {
26        &mut self.inode_reader
27    }
28
29    /// Read all (live) directory entries from inode `dir_ino`.
30    ///
31    /// Reads the inode's data blocks and calls `parse_dir_block` on each
32    /// block-sized chunk, then filters out deleted entries (inode == 0).
33    pub fn read_dir(&self, dir_ino: u64) -> Result<Vec<DirEntry>> {
34        let block_size = self.inode_reader.block_reader().block_size() as usize;
35        let data = self.inode_reader.read_inode_data(dir_ino)?;
36
37        let mut entries = Vec::new();
38        let mut offset = 0;
39        while offset < data.len() {
40            let end = (offset + block_size).min(data.len());
41            let chunk = &data[offset..end];
42            let block_entries = parse_dir_block(chunk);
43            for e in block_entries {
44                if e.inode != 0 {
45                    entries.push(e);
46                }
47            }
48            offset += block_size;
49        }
50        Ok(entries)
51    }
52
53    /// Look up a single name in directory inode `dir_ino`.
54    ///
55    /// Returns `Ok(Some(ino))` when found, `Ok(None)` when not found.
56    pub fn lookup(&self, dir_ino: u64, name: &[u8]) -> Result<Option<u64>> {
57        let entries = self.read_dir(dir_ino)?;
58        for e in entries {
59            if e.name.as_slice() == name {
60                return Ok(Some(u64::from(e.inode)));
61            }
62        }
63        Ok(None)
64    }
65
66    /// Resolve an absolute path to an inode number.
67    ///
68    /// Always starts at inode 2 (root). Follows symlinks up to
69    /// `MAX_SYMLINK_DEPTH` times. Returns `Ext4Error::PathNotFound` when
70    /// any component is missing.
71    pub fn resolve_path(&self, path: &str) -> Result<u64> {
72        self.resolve_path_inner(path, 0)
73    }
74
75    fn resolve_path_inner(&self, path: &str, depth: u32) -> Result<u64> {
76        if depth > MAX_SYMLINK_DEPTH {
77            return Err(Ext4Error::SymlinkLoop {
78                path: path.to_string(),
79                depth,
80            });
81        }
82
83        // All paths are absolute; start from root inode 2.
84        let mut cur_ino: u64 = 2;
85
86        let components: Vec<&str> = path
87            .trim_start_matches('/')
88            .split('/')
89            .filter(|c| !c.is_empty())
90            .collect();
91
92        for component in components {
93            // Resolve next component in cur_ino directory.
94            let next = self
95                .lookup(cur_ino, component.as_bytes())?
96                .ok_or_else(|| Ext4Error::PathNotFound(path.to_string()))?;
97
98            // Check whether the resolved entry is a symlink.
99            let inode = self.inode_reader.read_inode(next)?;
100            if inode.file_type() == FileType::Symlink {
101                let target = self.read_link(next)?;
102                let target_str = String::from_utf8_lossy(&target).into_owned();
103                if target_str.starts_with('/') {
104                    // Absolute symlink — restart from root.
105                    cur_ino = self.resolve_path_inner(&target_str, depth + 1)?;
106                } else {
107                    // Relative symlink — resolve from current directory.
108                    // Build a path relative to cur_ino's parent by treating
109                    // the symlink target as an absolute path under a synthetic
110                    // prefix derived from current directory.
111                    cur_ino = self.resolve_relative_link(cur_ino, &target_str, depth + 1)?;
112                }
113            } else {
114                cur_ino = next;
115            }
116        }
117
118        Ok(cur_ino)
119    }
120
121    /// Resolve a relative symlink `target` whose link inode is inside
122    /// directory `dir_ino`.
123    fn resolve_relative_link(&self, dir_ino: u64, target: &str, depth: u32) -> Result<u64> {
124        if depth > MAX_SYMLINK_DEPTH {
125            return Err(Ext4Error::SymlinkLoop {
126                path: target.to_string(),
127                depth,
128            });
129        }
130
131        let mut cur_ino = dir_ino;
132        let components: Vec<&str> = target.split('/').filter(|c| !c.is_empty()).collect();
133
134        for component in components {
135            match component {
136                "." => {}
137                ".." => {
138                    if let Some(parent) = self.lookup(cur_ino, b"..")? {
139                        cur_ino = parent;
140                    }
141                }
142                name => {
143                    let next = self
144                        .lookup(cur_ino, name.as_bytes())?
145                        .ok_or_else(|| Ext4Error::PathNotFound(target.to_string()))?;
146
147                    let inode = self.inode_reader.read_inode(next)?;
148                    if inode.file_type() == FileType::Symlink {
149                        let link_target = self.read_link(next)?;
150                        let link_str = String::from_utf8_lossy(&link_target).into_owned();
151                        if link_str.starts_with('/') {
152                            cur_ino = self.resolve_path_inner(&link_str, depth + 1)?;
153                        } else {
154                            cur_ino = self.resolve_relative_link(cur_ino, &link_str, depth + 1)?;
155                        }
156                    } else {
157                        cur_ino = next;
158                    }
159                }
160            }
161        }
162
163        Ok(cur_ino)
164    }
165
166    /// Read the target of a symlink inode.
167    ///
168    /// For short symlinks (size <= 60 and no extents), the path is stored
169    /// inline in `i_block`. Otherwise it is read from the data blocks.
170    pub fn read_link(&self, ino: u64) -> Result<Vec<u8>> {
171        let inode = self.inode_reader.read_inode(ino)?;
172        if inode.file_type() != FileType::Symlink {
173            return Err(Ext4Error::NotASymlink(format!("inode {ino}")));
174        }
175        // Inline symlink: size <= 60 and not using extents.
176        if inode.size <= 60 && !inode.uses_extents() {
177            let len = inode.size as usize;
178            return Ok(inode.i_block[..len].to_vec());
179        }
180        // Data-block symlink.
181        let data = self.inode_reader.read_inode_data(ino)?;
182        Ok(data)
183    }
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189    use crate::block::BlockReader;
190    use std::io::Cursor;
191
192    fn open_minimal() -> DirReader<Cursor<Vec<u8>>> {
193        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/minimal.img");
194        let data = std::fs::read(path).expect("minimal.img required");
195        let br = BlockReader::open(Cursor::new(data)).unwrap();
196        let ir = InodeReader::new(br);
197        DirReader::new(ir)
198    }
199
200    #[test]
201    fn read_root_directory() {
202        let r = open_minimal();
203        let entries = r.read_dir(2).unwrap();
204        let names: Vec<String> = entries
205            .iter()
206            .map(super::super::ondisk::dir_entry::DirEntry::name_str)
207            .collect();
208        assert!(names.contains(&".".to_string()));
209        assert!(names.contains(&"..".to_string()));
210        assert!(names.contains(&"hello.txt".to_string()));
211        assert!(names.contains(&"subdir".to_string()));
212        assert!(names.contains(&"lost+found".to_string()));
213    }
214
215    #[test]
216    fn lookup_file_in_root() {
217        let r = open_minimal();
218        let ino = r.lookup(2, b"hello.txt").unwrap();
219        assert!(ino.is_some());
220        assert!(ino.unwrap() > 0);
221    }
222
223    #[test]
224    fn lookup_nonexistent() {
225        let r = open_minimal();
226        let ino = r.lookup(2, b"nonexistent.txt").unwrap();
227        assert!(ino.is_none());
228    }
229
230    #[test]
231    fn resolve_path_root() {
232        let r = open_minimal();
233        assert_eq!(r.resolve_path("/").unwrap(), 2);
234    }
235
236    #[test]
237    fn resolve_path_file() {
238        let r = open_minimal();
239        let ino = r.resolve_path("/hello.txt").unwrap();
240        assert!(ino > 0);
241    }
242
243    #[test]
244    fn resolve_path_nested() {
245        let r = open_minimal();
246        let ino = r.resolve_path("/subdir/nested.txt").unwrap();
247        assert!(ino > 0);
248    }
249
250    #[test]
251    fn resolve_path_not_found() {
252        let r = open_minimal();
253        let err = r.resolve_path("/nonexistent").unwrap_err();
254        assert!(matches!(err, Ext4Error::PathNotFound(_)));
255    }
256
257    #[test]
258    fn read_file_content_via_path() {
259        let mut r = open_minimal();
260        let ino = r.resolve_path("/hello.txt").unwrap();
261        let data = r.inode_reader_mut().read_inode_data(ino).unwrap();
262        assert_eq!(data, b"Hello, ext4!");
263    }
264
265    fn open_forensic() -> Option<DirReader<Cursor<Vec<u8>>>> {
266        let path = concat!(env!("CARGO_MANIFEST_DIR"), "/../tests/data/forensic.img");
267        let data = std::fs::read(path).ok()?;
268        let br = BlockReader::open(Cursor::new(data)).ok()?;
269        let ir = InodeReader::new(br);
270        Some(DirReader::new(ir))
271    }
272
273    #[test]
274    fn inode_reader_accessor() {
275        let r = open_minimal();
276        // Verify inode_reader() returns a reference we can use to access the superblock.
277        let sb = r.inode_reader().block_reader().superblock();
278        assert!(sb.block_size > 0);
279    }
280
281    #[test]
282    fn resolve_absolute_symlink() {
283        let mut r = if let Some(r) = open_forensic() {
284            r
285        } else {
286            eprintln!("skip: forensic.img not found");
287            return;
288        };
289        // Read expected content from /hello.txt
290        let hello_ino = r.resolve_path("/hello.txt").unwrap();
291        let expected = r.inode_reader_mut().read_inode_data(hello_ino).unwrap();
292        // Resolve through absolute symlink
293        let link_ino = r.resolve_path("/abs-link").unwrap();
294        let actual = r.inode_reader_mut().read_inode_data(link_ino).unwrap();
295        assert_eq!(actual, expected);
296    }
297
298    #[test]
299    fn resolve_relative_symlink() {
300        let mut r = if let Some(r) = open_forensic() {
301            r
302        } else {
303            eprintln!("skip: forensic.img not found");
304            return;
305        };
306        let hello_ino = r.resolve_path("/hello.txt").unwrap();
307        let expected = r.inode_reader_mut().read_inode_data(hello_ino).unwrap();
308        let link_ino = r.resolve_path("/rel-link").unwrap();
309        let actual = r.inode_reader_mut().read_inode_data(link_ino).unwrap();
310        assert_eq!(actual, expected);
311    }
312
313    #[test]
314    fn resolve_deep_symlink() {
315        let mut r = if let Some(r) = open_forensic() {
316            r
317        } else {
318            eprintln!("skip: forensic.img not found");
319            return;
320        };
321        let nested_ino = r.resolve_path("/subdir/nested.txt").unwrap();
322        let expected = r.inode_reader_mut().read_inode_data(nested_ino).unwrap();
323        let link_ino = r.resolve_path("/deep-link").unwrap();
324        let actual = r.inode_reader_mut().read_inode_data(link_ino).unwrap();
325        assert_eq!(actual, expected);
326    }
327
328    #[test]
329    fn resolve_updir_symlink() {
330        let mut r = if let Some(r) = open_forensic() {
331            r
332        } else {
333            eprintln!("skip: forensic.img not found");
334            return;
335        };
336        let hello_ino = r.resolve_path("/hello.txt").unwrap();
337        let expected = r.inode_reader_mut().read_inode_data(hello_ino).unwrap();
338        let link_ino = r.resolve_path("/linkdir/up-link").unwrap();
339        let actual = r.inode_reader_mut().read_inode_data(link_ino).unwrap();
340        assert_eq!(actual, expected);
341    }
342
343    #[test]
344    fn read_link_inline() {
345        let r = if let Some(r) = open_forensic() {
346            r
347        } else {
348            eprintln!("skip: forensic.img not found");
349            return;
350        };
351        let link_ino = r.lookup(2, b"abs-link").unwrap().unwrap();
352        let target = r.read_link(link_ino).unwrap();
353        let target_str = String::from_utf8_lossy(&target);
354        assert_eq!(target_str, "/hello.txt");
355    }
356
357    #[test]
358    fn read_link_not_a_symlink() {
359        let r = if let Some(r) = open_forensic() {
360            r
361        } else {
362            eprintln!("skip: forensic.img not found");
363            return;
364        };
365        let file_ino = r.lookup(2, b"hello.txt").unwrap().unwrap();
366        let err = r.read_link(file_ino).unwrap_err();
367        assert!(matches!(err, Ext4Error::NotASymlink(_)));
368    }
369
370    #[test]
371    fn resolve_nonexistent_through_symlink() {
372        let r = if let Some(r) = open_forensic() {
373            r
374        } else {
375            eprintln!("skip: forensic.img not found");
376            return;
377        };
378        // Try resolving a path that doesn't exist — the symlink target is missing
379        let err = r.resolve_path("/nonexistent-link");
380        // If there's no such symlink entry, we get PathNotFound on the name itself
381        assert!(err.is_err());
382        assert!(matches!(err.unwrap_err(), Ext4Error::PathNotFound(_)));
383    }
384
385    #[test]
386    fn lookup_returns_none_for_missing_forensic() {
387        let r = if let Some(r) = open_forensic() {
388            r
389        } else {
390            eprintln!("skip: forensic.img not found");
391            return;
392        };
393        let result = r.lookup(2, b"does-not-exist.xyz").unwrap();
394        assert!(result.is_none());
395    }
396
397    #[test]
398    fn read_dir_handles_all_directory_types() {
399        let mut r = if let Some(r) = open_forensic() {
400            r
401        } else {
402            eprintln!("skip: forensic.img not found");
403            return;
404        };
405        let all_inodes = r.inode_reader_mut().iter_all_inodes().unwrap();
406        let mut dir_count = 0;
407        for (ino, inode) in &all_inodes {
408            if inode.file_type() == FileType::Directory && inode.mode != 0 {
409                let entries = r.read_dir(*ino).unwrap();
410                let names: Vec<String> = entries
411                    .iter()
412                    .map(super::super::ondisk::dir_entry::DirEntry::name_str)
413                    .collect();
414                assert!(
415                    names.contains(&".".to_string()),
416                    "dir ino {ino} missing '.'"
417                );
418                assert!(
419                    names.contains(&"..".to_string()),
420                    "dir ino {ino} missing '..'"
421                );
422                dir_count += 1;
423            }
424        }
425        assert!(dir_count > 0, "no directories found");
426    }
427}