Skip to main content

forensic_mount/
fs_sevenz.rs

1#![forbid(unsafe_code)]
2
3//! 7-Zip (`.7z`) archive mount support. Enabled with the `sevenz` feature flag.
4//!
5//! 7z is typically solid-compressed (one LZMA stream spans many files), so
6//! seeking to a single entry means decoding everything before it. The archive
7//! is therefore decoded once at open: every file's bytes are read in stream
8//! order into memory and indexed by an [`ArchiveTree`] synthetic inode.
9//!
10//! 7z is a read-only archive: no deleted inodes, no journal, no overlay.
11//! Encrypted archives are not handled (opened with an empty password).
12
13use crate::archive_tree::ArchiveTree;
14use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
15use sevenz_rust::{Password, SevenZReader};
16use std::io::{Read, Seek, SeekFrom};
17
18/// 100-ns ticks between the Windows FILETIME epoch (1601-01-01) and the Unix
19/// epoch (1970-01-01), used to convert 7z entry timestamps.
20const FILETIME_TO_UNIX_SECS: i64 = 11_644_473_600;
21
22/// `ForensicFs` implementation for 7-Zip archives.
23pub struct SevenZForensicFs {
24    tree: ArchiveTree,
25    /// File contents indexed by payload id (== position in this vector).
26    data: Vec<Vec<u8>>,
27}
28
29impl SevenZForensicFs {
30    /// Decode a `.7z` archive from a seekable source.
31    ///
32    /// # Errors
33    ///
34    /// [`FsError::Corrupt`] if the archive headers or streams are malformed.
35    pub fn new<R: Read + Seek>(mut source: R) -> Result<Self, FsError> {
36        let len = source.seek(SeekFrom::End(0)).map_err(FsError::Io)?;
37        source.seek(SeekFrom::Start(0)).map_err(FsError::Io)?;
38        let mut reader = SevenZReader::new(source, len, Password::empty())
39            .map_err(|e| FsError::Corrupt(format!("not a 7z: {e}")))?;
40
41        let mut tree = ArchiveTree::new();
42        let mut data: Vec<Vec<u8>> = Vec::new();
43        reader
44            .for_each_entries(|entry, rdr| {
45                let name = entry.name().to_string();
46                let mtime = filetime_to_ts(entry.last_modified_date().to_raw());
47                if entry.is_directory() {
48                    tree.insert(&name, true, 0, mtime, None);
49                } else {
50                    let mut buf = Vec::new();
51                    rdr.read_to_end(&mut buf)?;
52                    let id = data.len();
53                    if tree
54                        .insert(&name, false, buf.len() as u64, mtime, Some(id))
55                        .is_some()
56                    {
57                        data.push(buf);
58                    }
59                }
60                Ok(true)
61            })
62            .map_err(|e| FsError::Corrupt(format!("7z: {e}")))?;
63
64        Ok(Self { tree, data })
65    }
66}
67
68/// Convert a Windows FILETIME (100-ns ticks since 1601) to an `FsTimestamp`.
69/// A zero tick count (7z's "no timestamp") maps to the default (epoch-zero).
70fn filetime_to_ts(raw: u64) -> FsTimestamp {
71    if raw == 0 {
72        return FsTimestamp::default();
73    }
74    FsTimestamp {
75        seconds: (raw / 10_000_000) as i64 - FILETIME_TO_UNIX_SECS,
76        nanoseconds: ((raw % 10_000_000) * 100) as u32,
77    }
78}
79
80impl ForensicFs for SevenZForensicFs {
81    fn root_ino(&self) -> u64 {
82        self.tree.root_ino()
83    }
84
85    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
86        self.tree.read_dir(ino)
87    }
88
89    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
90        self.tree.lookup(parent_ino, name)
91    }
92
93    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
94        self.tree.metadata(ino)
95    }
96
97    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
98        let id = self
99            .tree
100            .payload_id(ino)
101            .ok_or_else(|| FsError::NotFound(format!("inode {ino} is not a file")))?;
102        self.data
103            .get(id)
104            .cloned()
105            .ok_or_else(|| FsError::NotFound(format!("payload {id}")))
106    }
107
108    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
109        let data = self.read_file(ino)?;
110        let start = (offset as usize).min(data.len());
111        let end = start.saturating_add(len as usize).min(data.len());
112        Ok(data[start..end].to_vec())
113    }
114
115    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
116        Err(not_supported("symlinks not surfaced for 7z archives"))
117    }
118
119    fn fs_info(&self) -> FsResult<serde_json::Value> {
120        Ok(serde_json::json!({
121            "type": "7z",
122            "entries": self.data.len(),
123        }))
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use std::io::Cursor;
131
132    /// Mint a real `.7z` with whichever 7-Zip CLI is installed (independent
133    /// oracle), holding `hello.txt` and `sub/deep.txt`. `None` if no 7z tool.
134    fn make_7z() -> Option<Vec<u8>> {
135        use std::sync::atomic::{AtomicU32, Ordering};
136        static SEQ: AtomicU32 = AtomicU32::new(0);
137        let uniq = SEQ.fetch_add(1, Ordering::Relaxed);
138        let dir = std::env::temp_dir().join(format!("4n67z_{}_{uniq}", std::process::id()));
139        let _ = std::fs::remove_dir_all(&dir);
140        std::fs::create_dir_all(dir.join("sub")).ok()?;
141        std::fs::write(dir.join("hello.txt"), b"hello 7z\n").ok()?;
142        std::fs::write(dir.join("sub/deep.txt"), b"deep content\n").ok()?;
143        let out = dir.join("test.7z");
144        let mut made = false;
145        for bin in ["7z", "7za", "7zz"] {
146            let status = std::process::Command::new(bin)
147                .current_dir(&dir)
148                .arg("a")
149                .arg(&out)
150                .args(["hello.txt", "sub"])
151                .stdout(std::process::Stdio::null())
152                .status();
153            if matches!(status, Ok(s) if s.success()) {
154                made = true;
155                break;
156            }
157        }
158        let bytes = if made { std::fs::read(&out).ok() } else { None };
159        let _ = std::fs::remove_dir_all(&dir);
160        bytes
161    }
162
163    fn open() -> Option<SevenZForensicFs> {
164        SevenZForensicFs::new(Cursor::new(make_7z()?)).ok()
165    }
166
167    #[test]
168    fn root_ino_is_2() {
169        let Some(fs) = open() else {
170            eprintln!("skip: no 7z tool");
171            return;
172        };
173        assert_eq!(fs.root_ino(), 2);
174    }
175
176    #[test]
177    fn read_dir_root_lists_entries() {
178        let Some(mut fs) = open() else {
179            eprintln!("skip");
180            return;
181        };
182        let names: Vec<String> = fs
183            .read_dir(2)
184            .unwrap()
185            .iter()
186            .map(FsDirEntry::name_str)
187            .collect();
188        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
189        assert!(names.contains(&"sub".to_string()), "got {names:?}");
190    }
191
192    #[test]
193    fn read_file_returns_content() {
194        let Some(mut fs) = open() else {
195            eprintln!("skip");
196            return;
197        };
198        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
199        assert_eq!(fs.read_file(ino).unwrap(), b"hello 7z\n");
200    }
201
202    #[test]
203    fn nested_file_reachable() {
204        let Some(mut fs) = open() else {
205            eprintln!("skip");
206            return;
207        };
208        let sub = fs.lookup(2, b"sub").unwrap().unwrap();
209        let deep = fs.lookup(sub, b"deep.txt").unwrap().unwrap();
210        assert_eq!(fs.read_file(deep).unwrap(), b"deep content\n");
211    }
212
213    #[test]
214    fn fs_info_reports_7z() {
215        let Some(fs) = open() else {
216            eprintln!("skip");
217            return;
218        };
219        assert_eq!(fs.fs_info().unwrap()["type"], "7z");
220    }
221
222    #[test]
223    fn filetime_zero_is_default() {
224        assert_eq!(filetime_to_ts(0), FsTimestamp::default());
225    }
226
227    #[test]
228    fn filetime_unix_epoch_converts() {
229        // FILETIME for 1970-01-01T00:00:00Z is exactly the offset in ticks.
230        let raw = (FILETIME_TO_UNIX_SECS as u64) * 10_000_000;
231        assert_eq!(filetime_to_ts(raw).seconds, 0);
232    }
233}