blob_decoder/lib.rs
1//! `blob-decoder` — identify and decode opaque forensic blobs of unknown type.
2//!
3//! Hand it raw bytes; it reports what they are, decodes them, and returns
4//! **scored, cited candidates** — recursively unwrapping nested wrappers (a
5//! base64'd, gzip'd binary-plist is reported as the full
6//! `Base64 → Gzip → BinaryPlist` chain).
7//!
8//! The actual decoding is delegated to mature crates (`plist`, `base64`, `hex`,
9//! `uuid`, `flate2`, `snap`, `serde_json`); this crate adds only the
10//! orchestration layer: identify → dispatch → score → recursively unwrap, plus a
11//! clean forensic result type.
12//!
13//! # Epistemics
14//!
15//! A blob is often underdetermined: bytes that are valid hex are frequently also
16//! valid base64, and a run of ASCII is *technically* decodable as base64 to
17//! gibberish. `blob-decoder` never asserts a single verdict — it returns every
18//! plausible reading with an honest [`Confidence`], and a low-confidence reading
19//! lowers the rank, never hides the finding.
20//!
21//! # Example
22//!
23//! ```
24//! // gzip magic (0x1f 0x8b) → identified as a Gzip wrapper.
25//! let gz = b"\x1f\x8b\x08\x00\x00\x00\x00\x00";
26//! let cands = blob_decoder::identify(gz);
27//! assert_eq!(cands[0].kind, blob_decoder::BlobKind::Gzip);
28//! ```
29#![forbid(unsafe_code)]
30#![cfg_attr(test, allow(clippy::unwrap_used, clippy::expect_used))]
31
32pub mod identify;
33pub mod v8_value;
34
35pub use identify::{identify, identify_with_limits};
36
37/// The engine's version (`CARGO_PKG_VERSION`), for callers that surface it.
38pub const VERSION: &str = env!("CARGO_PKG_VERSION");
39
40/// A recognised (or unrecognised) blob type.
41///
42/// The `citation` and `label` are carried per-kind so a reading is traceable to
43/// the authoritative format definition it was matched against.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
45#[serde(rename_all = "snake_case")]
46pub enum BlobKind {
47 /// Apple binary property list (`bplist00` magic).
48 BinaryPlist,
49 /// Apple XML property list.
50 XmlPlist,
51 /// gzip member (`1f 8b` magic).
52 Gzip,
53 /// zlib stream (RFC 1950 header).
54 Zlib,
55 /// Snappy framed stream.
56 Snappy,
57 /// base64 text (standard or URL-safe alphabet).
58 Base64,
59 /// Hexadecimal text.
60 Hex,
61 /// A UUID / GUID (16 raw bytes or the canonical hyphenated string).
62 Uuid,
63 /// JSON (object or array root).
64 Json,
65 /// Protocol Buffers wire-format message (schemaless / no `.proto`).
66 Protobuf,
67 /// A raw V8 structured-clone value (`ValueSerializer` output).
68 V8Serialized,
69 /// A Chromium/Blink `SerializedScriptValue` (the on-disk IndexedDB form —
70 /// a Blink envelope wrapping a V8 structured-clone payload).
71 BlinkSerialized,
72 /// UTF-16LE text.
73 Utf16Le,
74 /// UTF-8 text (printable).
75 Utf8Text,
76 /// No known type matched — the raw head bytes are reported for the analyst.
77 Unknown,
78}
79
80impl BlobKind {
81 /// A short human label.
82 #[must_use]
83 pub fn label(self) -> &'static str {
84 match self {
85 Self::BinaryPlist => "Apple binary property list",
86 Self::XmlPlist => "Apple XML property list",
87 Self::Gzip => "gzip stream",
88 Self::Zlib => "zlib stream",
89 Self::Snappy => "Snappy framed stream",
90 Self::Base64 => "base64 text",
91 Self::Hex => "hexadecimal text",
92 Self::Uuid => "UUID / GUID",
93 Self::Json => "JSON",
94 Self::Protobuf => "Protocol Buffers (schemaless)",
95 Self::V8Serialized => "V8 structured-clone value",
96 Self::BlinkSerialized => "Chromium/Blink SerializedScriptValue",
97 Self::Utf16Le => "UTF-16LE text",
98 Self::Utf8Text => "UTF-8 text",
99 Self::Unknown => "unknown",
100 }
101 }
102
103 /// The authoritative spec / reference this kind is matched against.
104 #[must_use]
105 pub fn citation(self) -> &'static str {
106 match self {
107 Self::BinaryPlist | Self::XmlPlist => {
108 "Apple CoreFoundation CFBinaryPList.c; `man 5 plist`"
109 }
110 Self::Gzip => "RFC 1952 (GZIP file format)",
111 Self::Zlib => "RFC 1950 (ZLIB compressed data format)",
112 Self::Snappy => "google/snappy framing_format.txt",
113 Self::Base64 => "RFC 4648 §4-5 (Base 64 / Base64url)",
114 Self::Hex => "RFC 4648 §8 (Base 16)",
115 Self::Uuid => "RFC 9562 (UUID)",
116 Self::Json => "RFC 8259 (JSON)",
117 Self::Protobuf => "protobuf.dev encoding spec (wire format)",
118 Self::V8Serialized => "V8 src/objects/value-serializer.cc (SerializationTag)",
119 Self::BlinkSerialized => "Blink serialization_tag.h; V8 value-serializer.cc",
120 Self::Utf16Le => "The Unicode Standard; RFC 2781 (UTF-16LE)",
121 Self::Utf8Text => "RFC 3629 (UTF-8)",
122 Self::Unknown => "no matching format",
123 }
124 }
125
126 /// True when this kind is a *wrapper* whose payload is itself another blob
127 /// (base64/hex text, or a compression stream) — the recursion drivers.
128 #[must_use]
129 pub fn is_wrapper(self) -> bool {
130 matches!(
131 self,
132 Self::Gzip | Self::Zlib | Self::Snappy | Self::Base64 | Self::Hex
133 )
134 }
135}
136
137/// How strongly the evidence supports a reading. Ordered `Low < Medium < High`
138/// so candidates sort best-first by *descending* confidence.
139///
140/// - [`Confidence::High`] — a strong, near-unique MAGIC signature or a full
141/// successful structural parse (`bplist00`, `1f 8b`, a valid RFC 1950 header,
142/// a parseable JSON object, a canonical hyphenated UUID string).
143/// - [`Confidence::Medium`] — a magic matched but the payload failed to fully
144/// decode, or a heuristic wrapper (base64/hex) whose decoded payload was
145/// itself recognised as a concrete type.
146/// - [`Confidence::Low`] — a purely structural heuristic that a random blob
147/// could satisfy by coincidence (16 arbitrary bytes as a UUID; base64/hex text
148/// decoding only to more opaque bytes; plain printable text).
149#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize)]
150#[serde(rename_all = "snake_case")]
151pub enum Confidence {
152 /// A coincidence-prone structural heuristic.
153 Low,
154 /// A magic matched but payload decode was partial, or a heuristic wrapper
155 /// whose payload was recognised.
156 Medium,
157 /// A strong magic signature or a full successful parse.
158 High,
159}
160
161/// One scored, cited candidate reading of a blob. A wrapper candidate nests the
162/// identification of its decoded payload in [`Candidate::inner`], so a
163/// `base64 → gzip → binary-plist` blob reports the whole chain.
164#[derive(Debug, Clone, serde::Serialize)]
165pub struct Candidate {
166 /// The identified (or unidentified) kind.
167 pub kind: BlobKind,
168 /// How strongly the evidence supports this reading.
169 pub score: Confidence,
170 /// A human summary of *what was found* (root type, byte counts, decoded
171 /// text prefix, or — for [`BlobKind::Unknown`] — the raw head bytes).
172 pub summary: String,
173 /// The authoritative spec citation for [`Candidate::kind`].
174 pub citation: &'static str,
175 /// For a wrapper kind, the identification of the decoded/decompressed
176 /// payload — the next link in the chain. `None` for a leaf reading, a failed
177 /// decode, or when the recursion depth cap was reached.
178 #[serde(skip_serializing_if = "Option::is_none")]
179 pub inner: Option<Box<DecodedChain>>,
180}
181
182/// The decoded payload of a wrapper [`Candidate`]: how many bytes it produced
183/// and the best reading of those bytes.
184#[derive(Debug, Clone, serde::Serialize)]
185pub struct DecodedChain {
186 /// Number of bytes the wrapper decoded/decompressed to (after any cap).
187 pub decoded_len: usize,
188 /// True when the decoded output was truncated at the size cap (a possible
189 /// decompression bomb) — the payload reading is of the capped prefix.
190 pub capped: bool,
191 /// The best (highest-confidence) reading of the decoded payload.
192 pub best: Box<Candidate>,
193}
194
195/// Resource bounds for [`identify_with_limits`] — the guard against
196/// decompression bombs and infinitely-nested wrappers on untrusted input.
197#[derive(Debug, Clone, Copy)]
198pub struct Limits {
199 /// Maximum recursion depth through nested wrappers.
200 pub max_depth: usize,
201 /// Maximum bytes to hold from a single decompression/decode step (a
202 /// decompression bomb is capped here, never allowed to exhaust memory).
203 pub max_output: usize,
204 /// Inputs larger than this skip the *heuristic* decoders (base64/hex/text);
205 /// magic-signature detection still runs. Bounds worst-case work.
206 pub max_input: usize,
207}
208
209impl Default for Limits {
210 fn default() -> Self {
211 Self {
212 max_depth: 8,
213 max_output: 64 * 1024 * 1024,
214 max_input: 128 * 1024 * 1024,
215 }
216 }
217}