1#![forbid(unsafe_code)]
2
3use crate::archive_tree::{civil_to_unix, ArchiveTree};
14use crate::{not_supported, ForensicFs, FsDirEntry, FsError, FsMetadata, FsResult, FsTimestamp};
15use std::io::{Read, Seek};
16
17pub struct ZipForensicFs<R: Read + Seek> {
19 archive: zip::ZipArchive<R>,
20 tree: ArchiveTree,
21}
22
23impl<R: Read + Seek> ZipForensicFs<R> {
24 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 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 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); }
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}