Skip to main content

forensic_mount/
fs_tar.rs

1#![forbid(unsafe_code)]
2
3//! tar archive mount support — gzip (`.tar.gz`/`.tgz`) or bzip2
4//! (`.tar.bz2`/`.tbz2`) compressed. Enabled with the `tarball` feature flag.
5//!
6//! A tar stream is sequential and the surrounding compressor is not seekable, so
7//! the archive is decoded once at open: every regular file's bytes are read into
8//! memory and indexed by a synthetic inode via [`ArchiveTree`]. Symlinks,
9//! devices, and other non-regular entries are counted and skipped (browsing a
10//! tar's file contents is the goal; a later revision can surface link targets).
11//!
12//! The tar walk is shared across compressors — only the decoder wrapping the
13//! source differs (`GzDecoder` vs `MultiBzDecoder`). tar is a read-only archive:
14//! no deleted inodes, no journal, no overlay.
15
16use crate::archive_tree::ArchiveTree;
17use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
18use std::io::{Read, Seek, SeekFrom};
19
20/// `ForensicFs` implementation for compressed tar archives.
21pub struct TarballForensicFs {
22    tree: ArchiveTree,
23    /// File contents indexed by payload id (== position in this vector).
24    data: Vec<Vec<u8>>,
25    /// Non-regular, non-directory entries skipped at open (symlinks, devices).
26    skipped: usize,
27    /// The compressor that wrapped the tar stream ("gzip" or "bzip2").
28    compression: &'static str,
29}
30
31impl TarballForensicFs {
32    /// Decode a gzip-compressed tar (`.tar.gz`) from a seekable source.
33    ///
34    /// # Errors
35    ///
36    /// [`FsError::Corrupt`] if the gzip or tar stream is malformed.
37    pub fn from_gz<R: Read + Seek>(mut source: R) -> Result<Self, FsError> {
38        source.seek(SeekFrom::Start(0)).map_err(FsError::Io)?;
39        Self::read_tar(flate2::read::GzDecoder::new(source), "gzip")
40    }
41
42    /// Decode a bzip2-compressed tar (`.tar.bz2`) from a seekable source.
43    ///
44    /// `MultiBzDecoder` is used so concatenated bzip2 streams (e.g. from
45    /// `pbzip2`) are decoded in full, not just the first.
46    ///
47    /// # Errors
48    ///
49    /// [`FsError::Corrupt`] if the bzip2 or tar stream is malformed.
50    pub fn from_bz2<R: Read + Seek>(mut source: R) -> Result<Self, FsError> {
51        source.seek(SeekFrom::Start(0)).map_err(FsError::Io)?;
52        Self::read_tar(bzip2::read::MultiBzDecoder::new(source), "bzip2")
53    }
54
55    /// Shared tar walk: read every entry from a decompressed stream, caching
56    /// regular files and building the directory tree.
57    fn read_tar<R: Read>(reader: R, compression: &'static str) -> Result<Self, FsError> {
58        let mut archive = tar::Archive::new(reader);
59        let mut tree = ArchiveTree::new();
60        let mut data: Vec<Vec<u8>> = Vec::new();
61        let mut skipped = 0usize;
62
63        let entries = archive
64            .entries()
65            .map_err(|e| FsError::Corrupt(format!("tar: {e}")))?;
66        for entry in entries {
67            let mut entry = entry.map_err(|e| FsError::Corrupt(format!("tar entry: {e}")))?;
68            let etype = entry.header().entry_type();
69            let mtime = FsTimestamp {
70                seconds: entry.header().mtime().unwrap_or(0) as i64,
71                nanoseconds: 0,
72            };
73            let path = entry
74                .path()
75                .map_err(|e| FsError::Corrupt(format!("tar path: {e}")))?
76                .to_string_lossy()
77                .into_owned();
78
79            if etype.is_dir() {
80                tree.insert(&path, true, 0, mtime, None);
81            } else if etype.is_file() {
82                let mut buf = Vec::new();
83                entry.read_to_end(&mut buf).map_err(FsError::Io)?;
84                let id = data.len();
85                if tree
86                    .insert(&path, false, buf.len() as u64, mtime, Some(id))
87                    .is_some()
88                {
89                    data.push(buf);
90                }
91            } else {
92                skipped += 1;
93            }
94        }
95
96        Ok(Self {
97            tree,
98            data,
99            skipped,
100            compression,
101        })
102    }
103}
104
105impl ForensicFs for TarballForensicFs {
106    fn root_ino(&self) -> u64 {
107        self.tree.root_ino()
108    }
109
110    fn read_dir(&mut self, ino: u64) -> FsResult<Vec<FsDirEntry>> {
111        self.tree.read_dir(ino)
112    }
113
114    fn lookup(&mut self, parent_ino: u64, name: &[u8]) -> FsResult<Option<u64>> {
115        self.tree.lookup(parent_ino, name)
116    }
117
118    fn metadata(&mut self, ino: u64) -> FsResult<FsMetadata> {
119        self.tree.metadata(ino)
120    }
121
122    fn read_file(&mut self, ino: u64) -> FsResult<Vec<u8>> {
123        let id = self
124            .tree
125            .payload_id(ino)
126            .ok_or_else(|| FsError::NotFound(format!("inode {ino} is not a file")))?;
127        self.data
128            .get(id)
129            .cloned()
130            .ok_or_else(|| FsError::NotFound(format!("payload {id}")))
131    }
132
133    fn read_file_range(&mut self, ino: u64, offset: u64, len: u64) -> FsResult<Vec<u8>> {
134        let data = self.read_file(ino)?;
135        let start = (offset as usize).min(data.len());
136        let end = start.saturating_add(len as usize).min(data.len());
137        Ok(data[start..end].to_vec())
138    }
139
140    fn read_link(&mut self, _ino: u64) -> FsResult<Vec<u8>> {
141        Err(not_supported("symlinks not surfaced for tar archives"))
142    }
143
144    fn fs_info(&self) -> FsResult<serde_json::Value> {
145        Ok(serde_json::json!({
146            "type": "tar",
147            "compression": self.compression,
148            "entries": self.data.len(),
149            "skipped_non_regular": self.skipped,
150        }))
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::io::Cursor;
158
159    /// Mint a real compressed tar with the system `tar` tool (an independent
160    /// oracle) containing `hello.txt` and `sub/deep.txt`. `flag` selects the
161    /// compressor: `-z` (gzip) or `-j` (bzip2). `None` if `tar` is unavailable.
162    fn make_tar(flag: &str) -> Option<Vec<u8>> {
163        use std::sync::atomic::{AtomicU32, Ordering};
164        static SEQ: AtomicU32 = AtomicU32::new(0);
165        let uniq = SEQ.fetch_add(1, Ordering::Relaxed);
166        let dir = std::env::temp_dir().join(format!("4n6tar_{}_{uniq}", std::process::id()));
167        let _ = std::fs::remove_dir_all(&dir);
168        std::fs::create_dir_all(dir.join("sub")).ok()?;
169        std::fs::write(dir.join("hello.txt"), b"hello tar\n").ok()?;
170        std::fs::write(dir.join("sub/deep.txt"), b"deep content\n").ok()?;
171        let out = dir.join("test.tar");
172        let status = std::process::Command::new("tar")
173            .arg(format!("-c{flag}f"))
174            .arg(&out)
175            .arg("-C")
176            .arg(&dir)
177            .args(["hello.txt", "sub/deep.txt"])
178            .status()
179            .ok()?;
180        if !status.success() {
181            return None;
182        }
183        let bytes = std::fs::read(&out).ok();
184        let _ = std::fs::remove_dir_all(&dir);
185        bytes
186    }
187
188    fn open_gz() -> Option<TarballForensicFs> {
189        TarballForensicFs::from_gz(Cursor::new(make_tar("z")?)).ok()
190    }
191
192    fn open_bz2() -> Option<TarballForensicFs> {
193        TarballForensicFs::from_bz2(Cursor::new(make_tar("j")?)).ok()
194    }
195
196    #[test]
197    fn gz_root_lists_entries() {
198        let Some(mut fs) = open_gz() else {
199            eprintln!("skip: tar unavailable");
200            return;
201        };
202        let names: Vec<String> = fs
203            .read_dir(2)
204            .unwrap()
205            .iter()
206            .map(FsDirEntry::name_str)
207            .collect();
208        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
209        assert!(names.contains(&"sub".to_string()), "got {names:?}");
210    }
211
212    #[test]
213    fn gz_read_file_and_nested() {
214        let Some(mut fs) = open_gz() else {
215            eprintln!("skip");
216            return;
217        };
218        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
219        assert_eq!(fs.read_file(ino).unwrap(), b"hello tar\n");
220        let sub = fs.lookup(2, b"sub").unwrap().unwrap();
221        let deep = fs.lookup(sub, b"deep.txt").unwrap().unwrap();
222        assert_eq!(fs.read_file(deep).unwrap(), b"deep content\n");
223    }
224
225    #[test]
226    fn gz_fs_info_reports_gzip() {
227        let Some(fs) = open_gz() else {
228            eprintln!("skip");
229            return;
230        };
231        let info = fs.fs_info().unwrap();
232        assert_eq!(info["type"], "tar");
233        assert_eq!(info["compression"], "gzip");
234    }
235
236    #[test]
237    fn bz2_root_lists_entries() {
238        let Some(mut fs) = open_bz2() else {
239            eprintln!("skip: tar unavailable");
240            return;
241        };
242        let names: Vec<String> = fs
243            .read_dir(2)
244            .unwrap()
245            .iter()
246            .map(FsDirEntry::name_str)
247            .collect();
248        assert!(names.contains(&"hello.txt".to_string()), "got {names:?}");
249        assert!(names.contains(&"sub".to_string()), "got {names:?}");
250    }
251
252    #[test]
253    fn bz2_read_file_and_nested() {
254        let Some(mut fs) = open_bz2() else {
255            eprintln!("skip");
256            return;
257        };
258        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
259        assert_eq!(fs.read_file(ino).unwrap(), b"hello tar\n");
260        let sub = fs.lookup(2, b"sub").unwrap().unwrap();
261        let deep = fs.lookup(sub, b"deep.txt").unwrap().unwrap();
262        assert_eq!(fs.read_file(deep).unwrap(), b"deep content\n");
263    }
264
265    #[test]
266    fn bz2_metadata_size_and_info() {
267        let Some(mut fs) = open_bz2() else {
268            eprintln!("skip");
269            return;
270        };
271        let ino = fs.lookup(2, b"hello.txt").unwrap().unwrap();
272        assert_eq!(fs.metadata(ino).unwrap().size, 10); // "hello tar\n"
273        assert_eq!(fs.fs_info().unwrap()["compression"], "bzip2");
274    }
275}