sqlite_forensic/blob.rs
1//! Recovered-BLOB enrichment (roadmap §4.5): make carved binary payloads
2//! addressable in a case by a magic-based media type and a SHA-256 content hash.
3//!
4//! The media detection reads only documented file-format **magic numbers** (each
5//! cited below) from the leading bytes — a signature match, not a decode, so it is
6//! panic-free and never mis-parses a truncated payload. The hash is SHA-256 via
7//! the audited `RustCrypto` `sha2` crate (never a hand-rolled digest), giving the
8//! court-standard content address every recovered image/document can be keyed on.
9
10use sha2::{Digest, Sha256};
11
12/// Identify a BLOB's media type from its leading magic bytes, or `None` when no
13/// known signature matches (or the payload is too short to carry one).
14///
15/// Signature match only — it does NOT validate the rest of the file, so a
16/// truncated or partially-overwritten carve still types by its surviving header.
17/// The type is an *observation* ("the leading bytes are a PNG signature"), never a
18/// guarantee the whole payload decodes.
19#[must_use]
20pub fn identify_media_type(bytes: &[u8]) -> Option<&'static str> {
21 // RIFF containers carry the concrete type at bytes 8..12 (e.g. WEBP, WAVE).
22 if bytes.len() >= 12 && &bytes[0..4] == b"RIFF" {
23 match &bytes[8..12] {
24 b"WEBP" => return Some("image/webp"),
25 b"WAVE" => return Some("audio/wav"),
26 b"AVI " => return Some("video/x-msvideo"),
27 _ => {}
28 }
29 }
30 // ISO base-media (MP4 and friends): `....ftyp` at offset 4.
31 if bytes.len() >= 8 && &bytes[4..8] == b"ftyp" {
32 return Some("video/mp4");
33 }
34 // Prefix signatures, longest/most-specific first. Each magic is a documented
35 // file-format constant.
36 const SIGNATURES: &[(&[u8], &str)] = &[
37 (b"\x89PNG\r\n\x1a\n", "image/png"), // PNG, RFC 2083 §3.1
38 (b"\xFF\xD8\xFF", "image/jpeg"), // JPEG/JFIF/EXIF SOI + marker
39 (b"GIF87a", "image/gif"), // GIF87a
40 (b"GIF89a", "image/gif"), // GIF89a
41 (b"BM", "image/bmp"), // BMP (Windows bitmap)
42 (b"II\x2a\x00", "image/tiff"), // TIFF little-endian
43 (b"MM\x00\x2a", "image/tiff"), // TIFF big-endian
44 (b"%PDF-", "application/pdf"), // PDF, ISO 32000 §7.5.2
45 (b"\x1f\x8b", "application/gzip"), // gzip, RFC 1952 §2.3.1
46 (b"PK\x03\x04", "application/zip"), // ZIP local file header
47 (b"BZh", "application/x-bzip2"), // bzip2
48 (b"7z\xbc\xaf\x27\x1c", "application/x-7z-compressed"), // 7-Zip
49 (b"SQLite format 3\x00", "application/vnd.sqlite3"), // SQLite db header
50 (b"OggS", "application/ogg"), // Ogg
51 (b"fLaC", "audio/flac"), // FLAC
52 (b"ID3", "audio/mpeg"), // MP3 with ID3 tag
53 (b"\x25\x21PS", "application/postscript"), // PostScript "%!PS"
54 (
55 b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1",
56 "application/x-ole-storage",
57 ), // OLE (legacy .doc/.xls)
58 ];
59 SIGNATURES
60 .iter()
61 .find(|(magic, _)| bytes.starts_with(magic))
62 .map(|(_, mime)| *mime)
63}
64
65/// The lowercase hex SHA-256 of `bytes` — the court-standard content address for a
66/// recovered BLOB, computed with the audited `RustCrypto` `sha2` implementation.
67#[must_use]
68pub fn sha256_hex(bytes: &[u8]) -> String {
69 let digest = Sha256::digest(bytes);
70 let mut out = String::with_capacity(digest.len() * 2);
71 for byte in digest {
72 // Two lowercase hex nibbles per byte.
73 out.push(char::from_digit(u32::from(byte >> 4), 16).unwrap_or('0'));
74 out.push(char::from_digit(u32::from(byte & 0x0f), 16).unwrap_or('0'));
75 }
76 out
77}