1#![forbid(unsafe_code)]
2
3use serde::{Deserialize, Serialize};
4use sha2::{Digest, Sha256};
5use std::collections::HashMap;
6use std::fs;
7use std::io::{self, Read, Write};
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12#[derive(Debug, Serialize, Deserialize)]
17pub struct SessionMetadata {
18 pub image_path: String,
19 pub image_sha256: String,
20 pub created: String,
21 pub examiner: String,
22}
23
24#[derive(Debug, Default, Serialize, Deserialize)]
25pub struct OverlayMetadata {
26 pub created: HashMap<String, OverlayEntry>,
27 pub modified: HashMap<u64, String>,
28 pub deleted: Vec<u64>,
29 pub dirs: HashMap<String, OverlayEntry>,
30}
31
32#[derive(Debug, Serialize, Deserialize)]
33pub struct OverlayEntry {
34 pub parent_ino: u64,
35 pub name: String,
36 pub size: u64,
37}
38
39#[derive(Debug)]
44pub struct Session {
45 pub dir: PathBuf,
46 pub metadata: SessionMetadata,
47 pub overlay: OverlayMetadata,
48}
49
50impl Session {
51 pub fn create(session_dir: &Path, image_path: &Path) -> io::Result<Self> {
54 fs::create_dir_all(session_dir)?;
55 fs::create_dir_all(session_dir.join("overlay"))?;
56
57 let hash = compute_image_hash(image_path)?;
58 let metadata = SessionMetadata {
59 image_path: image_path.to_string_lossy().into_owned(),
60 image_sha256: hash,
61 created: chrono_now(),
62 examiner: whoami(),
63 };
64 let overlay = OverlayMetadata::default();
65
66 let session = Self {
67 dir: session_dir.to_path_buf(),
68 metadata,
69 overlay,
70 };
71 session.save()?;
72 Ok(session)
73 }
74
75 pub fn resume(session_dir: &Path, image_path: &Path) -> io::Result<Self> {
77 let session_json = fs::read_to_string(session_dir.join("session.json"))?;
78 let metadata: SessionMetadata = serde_json::from_str(&session_json)
79 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
80
81 let current_hash = compute_image_hash(image_path)?;
82 if current_hash != metadata.image_sha256 {
83 return Err(io::Error::new(
84 io::ErrorKind::InvalidData,
85 format!(
86 "Image hash mismatch: expected {}, got {}",
87 metadata.image_sha256, current_hash
88 ),
89 ));
90 }
91
92 let overlay_path = session_dir.join("overlay").join("metadata.json");
93 let overlay: OverlayMetadata = if overlay_path.exists() {
94 let data = fs::read_to_string(&overlay_path)?;
95 serde_json::from_str(&data)
96 .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?
97 } else {
98 OverlayMetadata::default()
99 };
100
101 Ok(Self {
102 dir: session_dir.to_path_buf(),
103 metadata,
104 overlay,
105 })
106 }
107
108 pub fn save(&self) -> io::Result<()> {
110 let session_json =
111 serde_json::to_string_pretty(&self.metadata).map_err(io::Error::other)?;
112 fs::write(self.dir.join("session.json"), session_json)?;
113
114 let overlay_dir = self.dir.join("overlay");
115 fs::create_dir_all(&overlay_dir)?;
116 let overlay_json = serde_json::to_string_pretty(&self.overlay).map_err(io::Error::other)?;
117 fs::write(overlay_dir.join("metadata.json"), overlay_json)?;
118
119 Ok(())
120 }
121
122 pub fn overlay_file_path(&self, id: &str) -> PathBuf {
124 self.dir.join("overlay").join(id)
125 }
126
127 pub fn write_overlay_file(&self, id: &str, data: &[u8]) -> io::Result<()> {
129 let path = self.overlay_file_path(id);
130 let mut f = fs::File::create(&path)?;
131 f.write_all(data)?;
132 f.sync_all()?;
133 Ok(())
134 }
135
136 pub fn read_overlay_file(&self, id: &str) -> io::Result<Vec<u8>> {
138 fs::read(self.overlay_file_path(id))
139 }
140}
141
142pub fn export_session(session_dir: &Path, output: &Path) -> io::Result<()> {
148 let parent = session_dir
149 .parent()
150 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "session_dir has no parent"))?;
151 let dir_name = session_dir.file_name().ok_or_else(|| {
152 io::Error::new(io::ErrorKind::InvalidInput, "session_dir has no file name")
153 })?;
154
155 let status = Command::new("tar")
156 .arg("-czf")
157 .arg(output)
158 .arg("-C")
159 .arg(parent)
160 .arg(dir_name)
161 .status()?;
162
163 if !status.success() {
164 return Err(io::Error::other(format!("tar exited with {status}")));
165 }
166 Ok(())
167}
168
169pub fn import_session(tarball: &Path, session_dir: &Path) -> io::Result<()> {
171 fs::create_dir_all(session_dir)?;
172
173 let status = Command::new("tar")
174 .arg("-xzf")
175 .arg(tarball)
176 .arg("-C")
177 .arg(session_dir)
178 .arg("--strip-components=1")
179 .status()?;
180
181 if !status.success() {
182 return Err(io::Error::other(format!("tar exited with {status}")));
183 }
184 Ok(())
185}
186
187pub fn compute_image_hash(path: &Path) -> io::Result<String> {
193 let mut file = fs::File::open(path)?;
194 let mut hasher = Sha256::new();
195 let mut buf = [0u8; 8192];
196 loop {
197 let n = file.read(&mut buf)?;
198 if n == 0 {
199 break;
200 }
201 hasher.update(&buf[..n]);
202 }
203 Ok(format!("{:x}", hasher.finalize()))
204}
205
206pub fn chrono_now() -> String {
208 SystemTime::now()
209 .duration_since(UNIX_EPOCH)
210 .unwrap_or_default()
211 .as_secs()
212 .to_string()
213}
214
215pub fn whoami() -> String {
217 std::env::var("USER").unwrap_or_else(|_| "unknown".into())
218}
219
220#[cfg(test)]
225mod tests {
226 use super::*;
227 use std::fs;
228
229 fn tmp_dir(name: &str) -> PathBuf {
231 let dir = std::env::temp_dir().join(format!(
232 "forensic_mount_test_{}_{}",
233 name,
234 std::process::id()
235 ));
236 let _ = fs::remove_dir_all(&dir);
237 fs::create_dir_all(&dir).unwrap();
238 dir
239 }
240
241 #[test]
242 fn create_and_resume_session() {
243 let base = tmp_dir("create_resume");
244 let image = base.join("fake.img");
245 fs::write(&image, b"fake ext4 image data").unwrap();
246
247 let session_dir = base.join("session");
248 let session = Session::create(&session_dir, &image).unwrap();
249 assert!(!session.metadata.image_sha256.is_empty());
250 assert!(session_dir.join("session.json").exists());
251 assert!(session_dir.join("overlay/metadata.json").exists());
252
253 let resumed = Session::resume(&session_dir, &image).unwrap();
255 assert_eq!(resumed.metadata.image_sha256, session.metadata.image_sha256);
256
257 fs::remove_dir_all(&base).unwrap();
258 }
259
260 #[test]
261 fn resume_detects_tampered_image() {
262 let base = tmp_dir("tampered");
263 let image = base.join("fake.img");
264 fs::write(&image, b"original content").unwrap();
265
266 let session_dir = base.join("session");
267 Session::create(&session_dir, &image).unwrap();
268
269 fs::write(&image, b"tampered content").unwrap();
271
272 let err = Session::resume(&session_dir, &image).unwrap_err();
273 assert!(
274 err.to_string().contains("hash mismatch"),
275 "Expected hash mismatch error, got: {err}"
276 );
277
278 fs::remove_dir_all(&base).unwrap();
279 }
280
281 #[test]
282 fn save_persists_overlay_changes() {
283 let base = tmp_dir("save_persist");
284 let image = base.join("test.img");
285 fs::write(&image, b"image data").unwrap();
286
287 let session_dir = base.join("session");
288 let mut session = Session::create(&session_dir, &image).unwrap();
289
290 session.overlay.deleted.push(42);
292 session.overlay.modified.insert(100, "ino_100".to_string());
293 session.save().unwrap();
294
295 let resumed = Session::resume(&session_dir, &image).unwrap();
297 assert!(resumed.overlay.deleted.contains(&42));
298 assert_eq!(resumed.overlay.modified.get(&100).unwrap(), "ino_100");
299
300 let _ = fs::remove_dir_all(&base);
301 }
302
303 #[test]
304 fn overlay_file_path_construction() {
305 let base = tmp_dir("path_construct");
306 let image = base.join("test.img");
307 fs::write(&image, b"image").unwrap();
308
309 let session_dir = base.join("session");
310 let session = Session::create(&session_dir, &image).unwrap();
311
312 let path = session.overlay_file_path("file1");
313 assert!(path.ends_with("overlay/file1"));
314
315 let _ = fs::remove_dir_all(&base);
316 }
317
318 #[test]
319 fn export_import_session_roundtrip() {
320 let base = tmp_dir("export_import");
321 let image = base.join("test.img");
322 fs::write(&image, b"image for export").unwrap();
323
324 let session_dir = base.join("session");
326 let session = Session::create(&session_dir, &image).unwrap();
327 session
328 .write_overlay_file("test_file", b"overlay data")
329 .unwrap();
330
331 let tarball = base.join("export.tar.gz");
333 export_session(&session_dir, &tarball).unwrap();
334 assert!(tarball.exists());
335
336 let import_dir = base.join("imported");
338 import_session(&tarball, &import_dir).unwrap();
339
340 assert!(import_dir.join("session.json").exists());
342
343 let _ = fs::remove_dir_all(&base);
344 }
345
346 #[test]
347 fn overlay_file_roundtrip() {
348 let base = tmp_dir("overlay_rt");
349 let image = base.join("fake.img");
350 fs::write(&image, b"image bytes").unwrap();
351
352 let session_dir = base.join("session");
353 let session = Session::create(&session_dir, &image).unwrap();
354
355 let payload = b"hello overlay world";
356 session.write_overlay_file("test-file-1", payload).unwrap();
357 let read_back = session.read_overlay_file("test-file-1").unwrap();
358 assert_eq!(read_back, payload);
359
360 fs::remove_dir_all(&base).unwrap();
361 }
362}