1#![forbid(unsafe_code)]
2
3use crate::archive_tree::ArchiveTree;
17use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
18use std::io::{Read, Seek, SeekFrom};
19
20pub struct TarballForensicFs {
22 tree: ArchiveTree,
23 data: Vec<Vec<u8>>,
25 skipped: usize,
27 compression: &'static str,
29}
30
31impl TarballForensicFs {
32 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 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 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 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); assert_eq!(fs.fs_info().unwrap()["compression"], "bzip2");
274 }
275}