Skip to main content

forensic_mount/
session.rs

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// ---------------------------------------------------------------------------
13// Data types
14// ---------------------------------------------------------------------------
15
16#[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// ---------------------------------------------------------------------------
40// Session
41// ---------------------------------------------------------------------------
42
43#[derive(Debug)]
44pub struct Session {
45    pub dir: PathBuf,
46    pub metadata: SessionMetadata,
47    pub overlay: OverlayMetadata,
48}
49
50impl Session {
51    /// Create a new session, computing the SHA-256 of the image and persisting
52    /// `session.json` plus an empty `overlay/metadata.json`.
53    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    /// Resume an existing session, verifying the image hash matches.
76    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    /// Persist session.json and overlay/metadata.json to disk.
109    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    /// Return the filesystem path for an overlay file by id.
123    pub fn overlay_file_path(&self, id: &str) -> PathBuf {
124        self.dir.join("overlay").join(id)
125    }
126
127    /// Write data to an overlay file and fsync.
128    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    /// Read an overlay file.
137    pub fn read_overlay_file(&self, id: &str) -> io::Result<Vec<u8>> {
138        fs::read(self.overlay_file_path(id))
139    }
140}
141
142// ---------------------------------------------------------------------------
143// Free functions: export / import
144// ---------------------------------------------------------------------------
145
146/// Export a session directory as a gzipped tarball.
147pub 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
169/// Import a gzipped tarball into a session directory.
170pub 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
187// ---------------------------------------------------------------------------
188// Helpers
189// ---------------------------------------------------------------------------
190
191/// Compute the SHA-256 hex digest of a file.
192pub 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
206/// Current unix epoch as a string (no chrono dependency).
207pub fn chrono_now() -> String {
208    SystemTime::now()
209        .duration_since(UNIX_EPOCH)
210        .unwrap_or_default()
211        .as_secs()
212        .to_string()
213}
214
215/// Return the current user name from `$USER`.
216pub fn whoami() -> String {
217    std::env::var("USER").unwrap_or_else(|_| "unknown".into())
218}
219
220// ---------------------------------------------------------------------------
221// Tests
222// ---------------------------------------------------------------------------
223
224#[cfg(test)]
225mod tests {
226    use super::*;
227    use std::fs;
228
229    /// Create a unique temp dir for a test.
230    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        // Resume should succeed with the same image.
254        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        // Tamper with the image.
270        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        // Modify overlay
291        session.overlay.deleted.push(42);
292        session.overlay.modified.insert(100, "ino_100".to_string());
293        session.save().unwrap();
294
295        // Reload and verify
296        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        // Create session with overlay data
325        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        // Export
332        let tarball = base.join("export.tar.gz");
333        export_session(&session_dir, &tarball).unwrap();
334        assert!(tarball.exists());
335
336        // Import to new location
337        let import_dir = base.join("imported");
338        import_session(&tarball, &import_dir).unwrap();
339
340        // Verify session.json exists in imported dir
341        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}