Skip to main content

forensic_mount/
fs_zip.rs

1#![forbid(unsafe_code)]
2
3//! ZIP archive mount support. Enabled with the `zip` feature flag.
4//!
5//! ZIP has a central directory, so entries are random-access: the tree is built
6//! from the directory at open time and each file's bytes are decompressed
7//! on demand (avoiding materializing a whole — potentially bomb-sized — archive
8//! into memory up front). Directory entries map to [`ArchiveTree`] nodes; each
9//! file node's payload id is its central-directory index.
10//!
11//! ZIP is a read-only archive: no deleted inodes, no journal, no overlay.
12
13use crate::archive_tree::{civil_to_unix, ArchiveTree};
14use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
15use std::io::{Read, Seek};
16
17/// `ForensicFs` implementation for ZIP archives.
18pub struct ZipForensicFs<R: Read + Seek> {
19    archive: zip::ZipArchive<R>,
20    tree: ArchiveTree,
21}
22
23impl<R: Read + Seek> ZipForensicFs<R> {
24    /// Open a ZIP archive from a seekable source.
25    ///
26    /// # Errors
27    ///
28    /// [`FsError::Corrupt`] if the central directory cannot be read.
29    pub fn new(source: R) -> Result<Self, FsError> {
30        let mut archive = zip::ZipArchive::new(source)
31            .map_err(|e| FsError::Corrupt(format!("not a zip: {e}")))?;
32        let mut tree = ArchiveTree::new();
33        for i in 0..archive.len() {
34            let entry = archive
35                .by_index(i)
36                .map_err(|e| FsError::Corrupt(format!("zip entry {i}: {e}")))?;
37            let name = entry.name().to_string();
38            let is_dir = entry.is_dir();
39            let size = entry.size();
40            let mtime = entry
41                .last_modified()
42                .map_or_else(FsTimestamp::default, |dt| FsTimestamp {
43                    seconds: civil_to_unix(
44                        i64::from(dt.year()),
45                        i64::from(dt.month()),
46                        i64::from(dt.day()),
47                        i64::from(dt.hour()),
48                        i64::from(dt.minute()),
49                        i64::from(dt.second()),
50                    ),
51                    nanoseconds: 0,
52                });
53            tree.insert(
54                &name,
55                is_dir,
56                size,
57                mtime,
58                if is_dir { None } else { Some(i) },
59            );
60        }
61        Ok(Self { archive, tree })
62    }
63}
64
65impl<R: Read + Seek> ForensicFs for ZipForensicFs<R> {
66    fn root_ino(&self) -> u64 {
67        self.tree.root_ino()
68    }
69
70    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
71        self.tree.read_dir(ino)
72    }
73
74    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
75        self.tree.lookup(parent_ino, name)
76    }
77
78    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
79        self.tree.metadata(ino)
80    }
81
82    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
83        let idx = self
84            .tree
85            .payload_id(ino)
86            .ok_or_else(|| FsError::NotFound(format!("inode {ino} is not a file")))?;
87        let mut entry = self
88            .archive
89            .by_index(idx)
90            .map_err(|e| FsError::Corrupt(format!("zip entry {idx}: {e}")))?;
91        let mut buf = Vec::new();
92        entry.read_to_end(&mut buf).map_err(FsError::Io)?;
93        Ok(buf)
94    }
95
96    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
97        let data = self.read_file(ino)?;
98        let start = (offset as usize).min(data.len());
99        let end = start.saturating_add(len as usize).min(data.len());
100        Ok(data[start..end].to_vec())
101    }
102
103    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
104        Err(not_supported("symlinks not surfaced for zip archives"))
105    }
106
107    fn fs_info(&self) -> FsResult<serde_json::Value> {
108        Ok(serde_json::json!({
109            "type": "zip",
110            "entries": self.archive.len(),
111        }))
112    }
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118    use std::io::Cursor;
119
120    /// Mint a real `.zip` with the system `zip` tool (independent oracle)
121    /// holding `hello.txt` and `sub/deep.txt`. `None` if `zip` is unavailable.
122    fn make_zip() -> Option<Vec<u8>> {
123        use std::sync::atomic::{AtomicU32, Ordering};
124        static SEQ: AtomicU32 = AtomicU32::new(0);
125        let uniq = SEQ.fetch_add(1, Ordering::Relaxed);
126        let dir = std::env::temp_dir().join(format!("4n6zip_{}_{uniq}", std::process::id()));
127        let _ = std::fs::remove_dir_all(&dir);
128        std::fs::create_dir_all(dir.join("sub")).ok()?;
129        std::fs::write(dir.join("hello.txt"), b"hello zip\n").ok()?;
130        std::fs::write(dir.join("sub/deep.txt"), b"deep content\n").ok()?;
131        let out = dir.join("test.zip");
132        // `zip -r out.zip hello.txt sub` run from inside `dir`.
133        let status = std::process::Command::new("zip")
134            .current_dir(&dir)
135            .arg("-r")
136            .arg(&out)
137            .args(["hello.txt", "sub"])
138            .status()
139            .ok()?;
140        if !status.success() {
141            return None;
142        }
143        let bytes = std::fs::read(&out).ok();
144        let _ = std::fs::remove_dir_all(&dir);
145        bytes
146    }
147
148    fn open() -> Option<ZipForensicFs<Cursor<Vec<u8>>>> {
149        ZipForensicFs::new(Cursor::new(make_zip()?)).ok()
150    }
151
152    #[test]
153    fn root_ino_is_2() {
154        let Some(fs) = open() else {
155            eprintln!("skip: zip unavailable");
156            return;
157        };
158        assert_eq!(fs.root_ino(), 2);
159    }
160
161    #[test]
162    fn read_dir_root_lists_entries() {
163        let Some(mut fs) = open() else {
164            eprintln!("skip");
165            return;
166        };
167        let names: Vec<String> = fs
168            .read_dir(2)
169            .unwrap()
170            .iter()
171            .map(FsDirEntry::name_str)
172            .collect();
173        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
174        assert!(names.contains(&"sub".to_string()), "got {names:?}");
175    }
176
177    #[test]
178    fn read_file_returns_content() {
179        let Some(mut fs) = open() else {
180            eprintln!("skip");
181            return;
182        };
183        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
184        assert_eq!(fs.read_file(ino).unwrap(), b"hello zip\n");
185    }
186
187    #[test]
188    fn nested_file_reachable() {
189        let Some(mut fs) = open() else {
190            eprintln!("skip");
191            return;
192        };
193        let sub = fs.lookup(2, b"sub").unwrap().unwrap();
194        let deep = fs.lookup(sub, b"deep.txt").unwrap().unwrap();
195        assert_eq!(fs.read_file(deep).unwrap(), b"deep content\n");
196    }
197
198    #[test]
199    fn metadata_size_matches() {
200        let Some(mut fs) = open() else {
201            eprintln!("skip");
202            return;
203        };
204        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
205        assert_eq!(fs.metadata(ino).unwrap().size, 10); // "hello zip\n"
206    }
207
208    #[test]
209    fn fs_info_reports_zip() {
210        let Some(fs) = open() else {
211            eprintln!("skip");
212            return;
213        };
214        assert_eq!(fs.fs_info().unwrap()["type"], "zip");
215    }
216}