Skip to main content

blob_decoder/
identify.rs

1//! The orchestration engine: identify → dispatch → score → recursively unwrap.
2//!
3//! Every detector here is a thin dispatcher over a mature crate (`plist`,
4//! `flate2`, `snap`, `base64`, `hex`, `uuid`, `serde_json`) — this module owns
5//! only the *identification*, *scoring*, and *recursive unwrap*, never the codec
6//! itself. All input is attacker-controllable, so the invariant is: no panic, no
7//! OOM (every decompression is size-capped and the recursion is depth-capped),
8//! and every failure degrades to a lower-confidence or absent reading.
9
10use std::io::{Cursor, Read};
11
12use base64::Engine as _;
13use protobuf_forensic_core::{FieldValue, LenInterp};
14
15use crate::{BlobKind, Candidate, Confidence, DecodedChain, Limits};
16
17/// Identify a blob with default [`Limits`]. Returns scored candidates, best
18/// (highest [`Confidence`]) first. Always returns at least one candidate (an
19/// [`BlobKind::Unknown`] reading that surfaces the raw head bytes when nothing
20/// else matched).
21#[must_use]
22pub fn identify(bytes: &[u8]) -> Vec<Candidate> {
23    identify_with_limits(bytes, Limits::default(), 0)
24}
25
26/// Identify a blob with explicit resource [`Limits`] and a starting recursion
27/// `depth` — the entry point the recursive unwrap calls into.
28#[must_use]
29pub fn identify_with_limits(bytes: &[u8], limits: Limits, depth: usize) -> Vec<Candidate> {
30    let mut out: Vec<Candidate> = Vec::new();
31
32    // Strong magic / full-parse detectors always run (cheap, high-signal).
33    push(&mut out, detect_binary_plist(bytes));
34    push(&mut out, detect_xml_plist(bytes));
35    push(&mut out, detect_gzip(bytes, limits, depth));
36    push(&mut out, detect_zlib(bytes, limits, depth));
37    push(&mut out, detect_snappy(bytes, limits, depth));
38    push(&mut out, detect_json(bytes));
39    push(&mut out, detect_uuid_string(bytes));
40
41    // Heuristic detectors are bounded by max_input (they scan / decode the whole
42    // blob), so huge inputs get magic-only identification.
43    if bytes.len() <= limits.max_input {
44        push(&mut out, detect_base64(bytes, limits, depth));
45        push(&mut out, detect_hex(bytes, limits, depth));
46        push(&mut out, detect_uuid_bytes(bytes));
47        push(&mut out, detect_utf16le(bytes));
48        push(&mut out, detect_utf8_text(bytes));
49        // Protobuf is a *permissive* wire format — many random/other-format byte
50        // strings parse cleanly — so it runs last and is downgraded to Low when
51        // any High-confidence kind already matched (it is never the true reading
52        // of a gzip/plist/json blob).
53        let strong = out.iter().any(|c| c.score == Confidence::High);
54        push(&mut out, detect_protobuf(bytes, strong));
55    }
56
57    if out.is_empty() {
58        out.push(unknown(bytes));
59    }
60
61    out.sort_by(|a, b| {
62        b.score
63            .cmp(&a.score)
64            .then_with(|| kind_rank(b.kind).cmp(&kind_rank(a.kind)))
65            .then_with(|| a.kind.label().cmp(b.kind.label()))
66    });
67    out
68}
69
70fn push(out: &mut Vec<Candidate>, c: Option<Candidate>) {
71    if let Some(c) = c {
72        out.push(c);
73    }
74}
75
76/// Specificity tiebreak when two candidates share a [`Confidence`]: a concrete
77/// magic-identified type outranks a generic wrapper, which outranks bare text.
78fn kind_rank(kind: BlobKind) -> u8 {
79    match kind {
80        BlobKind::BinaryPlist
81        | BlobKind::XmlPlist
82        | BlobKind::Gzip
83        | BlobKind::Zlib
84        | BlobKind::Snappy
85        | BlobKind::Json
86        | BlobKind::Uuid => 3,
87        BlobKind::Base64 | BlobKind::Hex => 2,
88        // Protobuf sits at the bottom heuristic tier with bare text: a successful
89        // parse is a weak signal, so it never wins a confidence tie against a
90        // magic-identified (rank-3) or decoded-wrapper (rank-2) kind.
91        BlobKind::Protobuf | BlobKind::Utf16Le | BlobKind::Utf8Text => 1,
92        BlobKind::Unknown => 0,
93    }
94}
95
96// ---------------------------------------------------------------------------
97// Wrapper payload recursion
98// ---------------------------------------------------------------------------
99
100/// Build the [`DecodedChain`] for a wrapper's decoded payload: recurse to
101/// identify it, unless the depth cap is reached (the DoS backstop for
102/// infinitely-nested wrappers).
103fn build_chain(data: &[u8], capped: bool, limits: Limits, depth: usize) -> DecodedChain {
104    let best = if depth + 1 >= limits.max_depth {
105        Box::new(depth_capped(data))
106    } else {
107        identify_with_limits(data, limits, depth + 1)
108            .into_iter()
109            .next()
110            // cov:unreachable: identify_with_limits never returns empty (pushes Unknown), kept defensive fallback.
111            .map_or_else(|| Box::new(unknown(data)), Box::new)
112    };
113    DecodedChain {
114        decoded_len: data.len(),
115        capped,
116        best,
117    }
118}
119
120fn depth_capped(data: &[u8]) -> Candidate {
121    Candidate {
122        kind: BlobKind::Unknown,
123        score: Confidence::Low,
124        summary: format!(
125            "recursion depth cap reached; {} bytes not further decoded (head: {})",
126            data.len(),
127            head_hex(data)
128        ),
129        citation: BlobKind::Unknown.citation(),
130        inner: None,
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Bounded decompression (the decompression-bomb guard)
136// ---------------------------------------------------------------------------
137
138/// Read at most `cap` bytes from `r`; return the bytes and whether the stream
139/// was *capped* (had more to give). Bounds memory to `cap`, so a decompression
140/// bomb can never exhaust it.
141fn bounded_read<R: Read>(r: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)> {
142    let mut out = Vec::new();
143    // take(cap+1): if we get cap+1 bytes the stream had more → it was capped.
144    r.take(cap as u64 + 1).read_to_end(&mut out)?;
145    let capped = out.len() > cap;
146    if capped {
147        out.truncate(cap);
148    }
149    Ok((out, capped))
150}
151
152// ---------------------------------------------------------------------------
153// Detectors — strong magic / full parse
154// ---------------------------------------------------------------------------
155
156fn detect_binary_plist(bytes: &[u8]) -> Option<Candidate> {
157    if !bytes.starts_with(b"bplist") {
158        return None;
159    }
160    Some(match plist::Value::from_reader(Cursor::new(bytes)) {
161        Ok(v) => leaf(
162            BlobKind::BinaryPlist,
163            Confidence::High,
164            format!("binary plist: {}", describe_plist(&v)),
165        ),
166        Err(e) => leaf(
167            BlobKind::BinaryPlist,
168            Confidence::Medium,
169            format!("bplist magic but parse failed: {e}"),
170        ),
171    })
172}
173
174fn detect_xml_plist(bytes: &[u8]) -> Option<Candidate> {
175    let head = bytes.trim_ascii_start();
176    // Bounded prefix scan for the plist markers, so a huge non-plist XML body is
177    // rejected cheaply. Require the `plist` token to distinguish it from any XML.
178    let probe = &head[..head.len().min(1024)];
179    let starts_xml = probe.starts_with(b"<?xml")
180        || probe.starts_with(b"<plist")
181        || probe.starts_with(b"<!DOCTYPE");
182    if !starts_xml || !contains(probe, b"plist") {
183        return None;
184    }
185    Some(match plist::Value::from_reader(Cursor::new(bytes)) {
186        Ok(v) => leaf(
187            BlobKind::XmlPlist,
188            Confidence::High,
189            format!("XML plist: {}", describe_plist(&v)),
190        ),
191        Err(e) => leaf(
192            BlobKind::XmlPlist,
193            Confidence::Medium,
194            format!("XML plist markup but parse failed: {e}"),
195        ),
196    })
197}
198
199fn detect_gzip(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
200    // RFC 1952: magic 1f 8b. A 2-byte magic is specific enough that a decode
201    // failure is still worth reporting (as a truncated/corrupt gzip).
202    if !bytes.starts_with(&[0x1f, 0x8b]) {
203        return None;
204    }
205    match bounded_read(flate2::read::GzDecoder::new(bytes), limits.max_output) {
206        Ok((data, capped)) => Some(wrapper(
207            BlobKind::Gzip,
208            Confidence::High,
209            format!(
210                "gzip stream; {} bytes decompressed{}",
211                data.len(),
212                if capped { " (capped at limit)" } else { "" }
213            ),
214            build_chain(&data, capped, limits, depth),
215        )),
216        Err(e) => Some(leaf(
217            BlobKind::Gzip,
218            Confidence::Medium,
219            format!("gzip magic but decompression failed: {e}"),
220        )),
221    }
222}
223
224fn detect_zlib(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
225    // RFC 1950 header has no unique magic — only CM=deflate + the FCHECK mod-31
226    // constraint (a weak ~1/500 filter). So we claim zlib ONLY on a SUCCESSFUL
227    // decompress; a header match that fails to inflate is treated as coincidence
228    // (returns None), never a false Medium on random bytes.
229    if bytes.len() < 2 {
230        return None;
231    }
232    let (cmf, flg) = (bytes[0], bytes[1]);
233    if cmf & 0x0f != 0x08 || cmf >> 4 > 7 {
234        return None;
235    }
236    if !((u16::from(cmf) << 8) | u16::from(flg)).is_multiple_of(31) {
237        return None;
238    }
239    let (data, capped) =
240        bounded_read(flate2::read::ZlibDecoder::new(bytes), limits.max_output).ok()?;
241    Some(wrapper(
242        BlobKind::Zlib,
243        Confidence::High,
244        format!(
245            "zlib stream; {} bytes decompressed{}",
246            data.len(),
247            if capped { " (capped at limit)" } else { "" }
248        ),
249        build_chain(&data, capped, limits, depth),
250    ))
251}
252
253fn detect_snappy(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
254    // Snappy framing format: the stream identifier chunk (0xff + "sNaPpY").
255    const MAGIC: &[u8] = &[0xff, 0x06, 0x00, 0x00, 0x73, 0x4e, 0x61, 0x50, 0x70, 0x59];
256    if !bytes.starts_with(MAGIC) {
257        return None;
258    }
259    match bounded_read(snap::read::FrameDecoder::new(bytes), limits.max_output) {
260        Ok((data, capped)) => Some(wrapper(
261            BlobKind::Snappy,
262            Confidence::High,
263            format!(
264                "Snappy framed stream; {} bytes decompressed{}",
265                data.len(),
266                if capped { " (capped at limit)" } else { "" }
267            ),
268            build_chain(&data, capped, limits, depth),
269        )),
270        Err(e) => Some(leaf(
271            BlobKind::Snappy,
272            Confidence::Medium,
273            format!("Snappy magic but decompression failed: {e}"),
274        )),
275    }
276}
277
278fn detect_json(bytes: &[u8]) -> Option<Candidate> {
279    let trimmed = bytes.trim_ascii();
280    // Only object/array roots are claimed as JSON: a bare `123` or `true` is
281    // technically JSON but too ambiguous to assert.
282    if !matches!(trimmed.first(), Some(b'{' | b'[')) {
283        return None;
284    }
285    let value: serde_json::Value = serde_json::from_slice(trimmed).ok()?;
286    Some(leaf(
287        BlobKind::Json,
288        Confidence::High,
289        describe_json(&value),
290    ))
291}
292
293fn detect_uuid_string(bytes: &[u8]) -> Option<Candidate> {
294    let s = std::str::from_utf8(bytes).ok()?.trim();
295    // Require the hyphenated (or braced/urn) canonical form: a bare 32-hex run is
296    // better read as hex, so we do not claim it here.
297    if !s.contains('-') {
298        return None;
299    }
300    let u = uuid::Uuid::try_parse(s).ok()?;
301    Some(leaf(
302        BlobKind::Uuid,
303        Confidence::High,
304        format!(
305            "UUID {u} (version {}, variant {:?})",
306            u.get_version_num(),
307            u.get_variant()
308        ),
309    ))
310}
311
312// ---------------------------------------------------------------------------
313// Detectors — heuristic (coincidence-prone, scored Low unless the payload is
314// itself recognised)
315// ---------------------------------------------------------------------------
316
317fn detect_base64(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
318    let decoded = try_base64(bytes)?;
319    let chain = build_chain(&decoded, false, limits, depth);
320    let score = wrapper_score(chain.best.kind);
321    Some(wrapper(
322        BlobKind::Base64,
323        score,
324        format!("base64 text; decodes to {} bytes", chain.decoded_len),
325        chain,
326    ))
327}
328
329fn detect_hex(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
330    let s = bytes.trim_ascii();
331    if s.len() < 4 || !s.len().is_multiple_of(2) || !s.iter().all(u8::is_ascii_hexdigit) {
332        return None;
333    }
334    let decoded = hex::decode(s).ok()?;
335    let chain = build_chain(&decoded, false, limits, depth);
336    let score = wrapper_score(chain.best.kind);
337    Some(wrapper(
338        BlobKind::Hex,
339        score,
340        format!("hexadecimal text; decodes to {} bytes", chain.decoded_len),
341        chain,
342    ))
343}
344
345fn detect_uuid_bytes(bytes: &[u8]) -> Option<Candidate> {
346    let arr: [u8; 16] = bytes.try_into().ok()?;
347    let u = uuid::Uuid::from_bytes(arr);
348    Some(leaf(
349        BlobKind::Uuid,
350        // Any 16 bytes form a syntactically valid UUID — never over-claim.
351        Confidence::Low,
352        format!("if a UUID: {u} (note: any 16 bytes form a valid UUID)"),
353    ))
354}
355
356fn detect_utf16le(bytes: &[u8]) -> Option<Candidate> {
357    if bytes.len() < 4 || !bytes.len().is_multiple_of(2) {
358        return None;
359    }
360    let has_bom = bytes.starts_with(&[0xff, 0xfe]);
361    let body = if has_bom { &bytes[2..] } else { bytes };
362    let units: Vec<u16> = body
363        .chunks_exact(2)
364        .map(|c| u16::from_le_bytes([c[0], c[1]]))
365        .collect();
366    if units.is_empty() {
367        return None;
368    }
369    let text = String::from_utf16(&units).ok()?;
370    if !mostly_printable(&text) {
371        return None;
372    }
373    // Without a BOM, require the ASCII-plane dominance that real UTF-16LE text
374    // shows (high byte zero), else even-length binary would masquerade as text.
375    let ascii_plane = body.chunks_exact(2).filter(|c| c[1] == 0).count();
376    if !has_bom && (ascii_plane * 2) < units.len() {
377        return None;
378    }
379    Some(leaf(
380        BlobKind::Utf16Le,
381        if has_bom {
382            Confidence::Medium
383        } else {
384            Confidence::Low
385        },
386        format!("UTF-16LE text preview: \"{}\"", preview(&text)),
387    ))
388}
389
390fn detect_utf8_text(bytes: &[u8]) -> Option<Candidate> {
391    let s = std::str::from_utf8(bytes).ok()?;
392    if s.is_empty() || !mostly_printable(s) {
393        return None;
394    }
395    Some(leaf(
396        BlobKind::Utf8Text,
397        Confidence::Low,
398        format!("UTF-8 text preview: \"{}\"", preview(s)),
399    ))
400}
401
402/// Attempt a schemaless protobuf decode (delegated to `protobuf-forensic-core`).
403/// A candidate is offered ONLY when the whole input decodes as a valid message
404/// with ≥1 field — no trailing garbage, no empty message.
405///
406/// Scoring is deliberately conservative: protobuf is a *permissive* wire format
407/// (a large fraction of arbitrary byte strings parse as "valid protobuf"), so a
408/// bare successful parse is a WEAK signal and scores [`Confidence::Low`]. It is
409/// lifted to [`Confidence::Medium`] only on a corroborating signal — the message
410/// carries a nested submessage or a string field (structure a coincidental parse
411/// rarely produces) AND no High-confidence kind already matched (`strong_present`),
412/// so a gzip/plist/json blob that also happens to parse never gets a Medium.
413fn detect_protobuf(bytes: &[u8], strong_present: bool) -> Option<Candidate> {
414    let fields = protobuf_forensic_core::decode(bytes).ok()?;
415    if fields.is_empty() {
416        return None;
417    }
418    let (submessages, strings) = count_structure(&fields);
419    let structured = submessages > 0 || strings > 0;
420    let score = if structured && !strong_present {
421        Confidence::Medium
422    } else {
423        Confidence::Low
424    };
425    Some(leaf(
426        BlobKind::Protobuf,
427        score,
428        format!(
429            "protobuf wire-format message: {} field{} ({submessages} submessage{}, {strings} string{})",
430            fields.len(),
431            plural(fields.len()),
432            plural(submessages),
433            plural(strings),
434        ),
435    ))
436}
437
438/// Count the top-level length-delimited fields inferred as nested submessages and
439/// as strings — the corroborating structure that distinguishes a plausibly-real
440/// message from a coincidental parse of opaque scalars.
441fn count_structure(fields: &[protobuf_forensic_core::Field]) -> (usize, usize) {
442    let mut submessages = 0;
443    let mut strings = 0;
444    for f in fields {
445        if let FieldValue::Len(lv) = &f.value {
446            match lv.interp {
447                LenInterp::Message(_) => submessages += 1,
448                LenInterp::Text(_) => strings += 1,
449                LenInterp::Bytes => {}
450            }
451        }
452    }
453    (submessages, strings)
454}
455
456fn plural(n: usize) -> &'static str {
457    if n == 1 {
458        ""
459    } else {
460        "s"
461    }
462}
463
464// ---------------------------------------------------------------------------
465// Scoring / construction helpers
466// ---------------------------------------------------------------------------
467
468/// A wrapper (base64/hex) whose decoded payload is itself a concrete recognised
469/// type is Medium; one that decodes only to opaque bytes or plain text is Low
470/// (the decode was probably coincidental).
471fn wrapper_score(inner: BlobKind) -> Confidence {
472    match inner {
473        BlobKind::Unknown | BlobKind::Utf8Text | BlobKind::Utf16Le => Confidence::Low,
474        _ => Confidence::Medium,
475    }
476}
477
478fn leaf(kind: BlobKind, score: Confidence, summary: String) -> Candidate {
479    Candidate {
480        kind,
481        score,
482        summary,
483        citation: kind.citation(),
484        inner: None,
485    }
486}
487
488fn wrapper(kind: BlobKind, score: Confidence, summary: String, chain: DecodedChain) -> Candidate {
489    Candidate {
490        kind,
491        score,
492        summary,
493        citation: kind.citation(),
494        inner: Some(Box::new(chain)),
495    }
496}
497
498fn unknown(bytes: &[u8]) -> Candidate {
499    Candidate {
500        kind: BlobKind::Unknown,
501        score: Confidence::Low,
502        summary: if bytes.is_empty() {
503            "unrecognized: empty input".to_owned()
504        } else {
505            format!(
506                "unrecognized; {} bytes (head: {})",
507                bytes.len(),
508                head_hex(bytes)
509            )
510        },
511        citation: BlobKind::Unknown.citation(),
512        inner: None,
513    }
514}
515
516/// Validate + decode base64 (standard or URL-safe, whitespace-tolerant). Returns
517/// the decoded bytes, or `None` if the input is not clean, correctly-padded
518/// base64 — delegating the decode itself to the `base64` crate.
519fn try_base64(bytes: &[u8]) -> Option<Vec<u8>> {
520    let cleaned: Vec<u8> = bytes
521        .iter()
522        .copied()
523        .filter(|b| !b.is_ascii_whitespace())
524        .collect();
525    if cleaned.len() < 8 || !cleaned.len().is_multiple_of(4) {
526        return None;
527    }
528    let eq = cleaned
529        .iter()
530        .position(|&b| b == b'=')
531        .unwrap_or(cleaned.len());
532    let (body, padding) = cleaned.split_at(eq);
533    if padding.len() > 2 || padding.iter().any(|&b| b != b'=') || body.is_empty() {
534        return None;
535    }
536    let is_std = body
537        .iter()
538        .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
539    let is_url = body
540        .iter()
541        .all(|&b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
542    if is_std {
543        base64::engine::general_purpose::STANDARD
544            .decode(&cleaned)
545            .ok()
546    } else if is_url {
547        base64::engine::general_purpose::URL_SAFE
548            .decode(&cleaned)
549            .ok()
550    } else {
551        None
552    }
553}
554
555fn describe_plist(v: &plist::Value) -> String {
556    match v {
557        plist::Value::Array(a) => format!("array with {} items", a.len()),
558        plist::Value::Dictionary(d) => format!("dict with {} entries", d.len()),
559        plist::Value::Boolean(_) => "boolean".to_owned(),
560        plist::Value::Data(d) => format!("data ({} bytes)", d.len()),
561        plist::Value::Date(_) => "date".to_owned(),
562        plist::Value::Real(_) => "real".to_owned(),
563        plist::Value::Integer(_) => "integer".to_owned(),
564        plist::Value::String(_) => "string".to_owned(),
565        plist::Value::Uid(_) => "uid".to_owned(),
566        _ => "value".to_owned(),
567    }
568}
569
570fn describe_json(v: &serde_json::Value) -> String {
571    match v {
572        serde_json::Value::Object(m) => format!("JSON object with {} keys", m.len()),
573        serde_json::Value::Array(a) => format!("JSON array with {} elements", a.len()),
574        // Unreachable: detect_json only enters on `{`/`[` roots.
575        _ => "JSON value".to_owned(),
576    }
577}
578
579fn contains(haystack: &[u8], needle: &[u8]) -> bool {
580    haystack.windows(needle.len()).any(|w| w == needle)
581}
582
583fn mostly_printable(s: &str) -> bool {
584    let total = s.chars().count();
585    if total == 0 {
586        return false;
587    }
588    let printable = s
589        .chars()
590        .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
591        .count();
592    (printable * 100) >= (total * 90)
593}
594
595fn head_hex(bytes: &[u8]) -> String {
596    let n = bytes.len().min(16);
597    let mut s = hex::encode(&bytes[..n]);
598    if bytes.len() > n {
599        s = format!("{s} (+{} more)", bytes.len() - n);
600    }
601    s
602}
603
604fn preview(s: &str) -> String {
605    const MAX: usize = 48;
606    let flat: String = s
607        .chars()
608        .map(|c| if c.is_control() { ' ' } else { c })
609        .collect();
610    if flat.chars().count() <= MAX {
611        flat
612    } else {
613        let cut: String = flat.chars().take(MAX).collect();
614        format!("{cut}… ({} chars total)", s.chars().count())
615    }
616}