1use std::io::{Cursor, Read};
11
12use base64::Engine as _;
13use protobuf_forensic_core::{FieldValue, LenInterp};
14
15use crate::{v8_value, BlobKind, Candidate, Confidence, DecodedChain, Limits};
16
17#[must_use]
22pub fn identify(bytes: &[u8]) -> Vec<Candidate> {
23 identify_with_limits(bytes, Limits::default(), 0)
24}
25
26#[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 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 push(&mut out, detect_v8_blink(bytes));
41
42 if bytes.len() <= limits.max_input {
45 push(&mut out, detect_base64(bytes, limits, depth));
46 push(&mut out, detect_hex(bytes, limits, depth));
47 push(&mut out, detect_uuid_bytes(bytes));
48 push(&mut out, detect_utf16le(bytes));
49 push(&mut out, detect_utf8_text(bytes));
50 let strong = out.iter().any(|c| c.score == Confidence::High);
55 push(&mut out, detect_protobuf(bytes, strong));
56 }
57
58 if out.is_empty() {
59 out.push(unknown(bytes));
60 }
61
62 out.sort_by(|a, b| {
63 b.score
64 .cmp(&a.score)
65 .then_with(|| kind_rank(b.kind).cmp(&kind_rank(a.kind)))
66 .then_with(|| a.kind.label().cmp(b.kind.label()))
67 });
68 out
69}
70
71fn push(out: &mut Vec<Candidate>, c: Option<Candidate>) {
72 if let Some(c) = c {
73 out.push(c);
74 }
75}
76
77fn kind_rank(kind: BlobKind) -> u8 {
80 match kind {
81 BlobKind::BinaryPlist
82 | BlobKind::XmlPlist
83 | BlobKind::Gzip
84 | BlobKind::Zlib
85 | BlobKind::Snappy
86 | BlobKind::Json
87 | BlobKind::Uuid
88 | BlobKind::V8Serialized
89 | BlobKind::BlinkSerialized => 3,
90 BlobKind::Base64 | BlobKind::Hex => 2,
91 BlobKind::Protobuf | BlobKind::Utf16Le | BlobKind::Utf8Text => 1,
95 BlobKind::Unknown => 0,
96 }
97}
98
99fn build_chain(data: &[u8], capped: bool, limits: Limits, depth: usize) -> DecodedChain {
107 let best = if depth + 1 >= limits.max_depth {
108 Box::new(depth_capped(data))
109 } else {
110 identify_with_limits(data, limits, depth + 1)
111 .into_iter()
112 .next()
113 .map_or_else(|| Box::new(unknown(data)), Box::new)
115 };
116 DecodedChain {
117 decoded_len: data.len(),
118 capped,
119 best,
120 }
121}
122
123fn depth_capped(data: &[u8]) -> Candidate {
124 Candidate {
125 kind: BlobKind::Unknown,
126 score: Confidence::Low,
127 summary: format!(
128 "recursion depth cap reached; {} bytes not further decoded (head: {})",
129 data.len(),
130 head_hex(data)
131 ),
132 citation: BlobKind::Unknown.citation(),
133 inner: None,
134 }
135}
136
137fn bounded_read<R: Read>(r: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)> {
145 let mut out = Vec::new();
146 r.take(cap as u64 + 1).read_to_end(&mut out)?;
148 let capped = out.len() > cap;
149 if capped {
150 out.truncate(cap);
151 }
152 Ok((out, capped))
153}
154
155fn detect_binary_plist(bytes: &[u8]) -> Option<Candidate> {
160 if !bytes.starts_with(b"bplist") {
161 return None;
162 }
163 Some(match plist::Value::from_reader(Cursor::new(bytes)) {
164 Ok(v) => leaf(
165 BlobKind::BinaryPlist,
166 Confidence::High,
167 format!("binary plist: {}", describe_plist(&v)),
168 ),
169 Err(e) => leaf(
170 BlobKind::BinaryPlist,
171 Confidence::Medium,
172 format!("bplist magic but parse failed: {e}"),
173 ),
174 })
175}
176
177fn detect_xml_plist(bytes: &[u8]) -> Option<Candidate> {
178 let head = bytes.trim_ascii_start();
179 let probe = &head[..head.len().min(1024)];
182 let starts_xml = probe.starts_with(b"<?xml")
183 || probe.starts_with(b"<plist")
184 || probe.starts_with(b"<!DOCTYPE");
185 if !starts_xml || !contains(probe, b"plist") {
186 return None;
187 }
188 Some(match plist::Value::from_reader(Cursor::new(bytes)) {
189 Ok(v) => leaf(
190 BlobKind::XmlPlist,
191 Confidence::High,
192 format!("XML plist: {}", describe_plist(&v)),
193 ),
194 Err(e) => leaf(
195 BlobKind::XmlPlist,
196 Confidence::Medium,
197 format!("XML plist markup but parse failed: {e}"),
198 ),
199 })
200}
201
202fn detect_gzip(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
203 if !bytes.starts_with(&[0x1f, 0x8b]) {
206 return None;
207 }
208 match bounded_read(flate2::read::GzDecoder::new(bytes), limits.max_output) {
209 Ok((data, capped)) => Some(wrapper(
210 BlobKind::Gzip,
211 Confidence::High,
212 format!(
213 "gzip stream; {} bytes decompressed{}",
214 data.len(),
215 if capped { " (capped at limit)" } else { "" }
216 ),
217 build_chain(&data, capped, limits, depth),
218 )),
219 Err(e) => Some(leaf(
220 BlobKind::Gzip,
221 Confidence::Medium,
222 format!("gzip magic but decompression failed: {e}"),
223 )),
224 }
225}
226
227fn detect_zlib(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
228 if bytes.len() < 2 {
233 return None;
234 }
235 let (cmf, flg) = (bytes[0], bytes[1]);
236 if cmf & 0x0f != 0x08 || cmf >> 4 > 7 {
237 return None;
238 }
239 if !((u16::from(cmf) << 8) | u16::from(flg)).is_multiple_of(31) {
240 return None;
241 }
242 let (data, capped) =
243 bounded_read(flate2::read::ZlibDecoder::new(bytes), limits.max_output).ok()?;
244 Some(wrapper(
245 BlobKind::Zlib,
246 Confidence::High,
247 format!(
248 "zlib stream; {} bytes decompressed{}",
249 data.len(),
250 if capped { " (capped at limit)" } else { "" }
251 ),
252 build_chain(&data, capped, limits, depth),
253 ))
254}
255
256fn detect_snappy(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
257 const MAGIC: &[u8] = &[0xff, 0x06, 0x00, 0x00, 0x73, 0x4e, 0x61, 0x50, 0x70, 0x59];
259 if !bytes.starts_with(MAGIC) {
260 return None;
261 }
262 match bounded_read(snap::read::FrameDecoder::new(bytes), limits.max_output) {
263 Ok((data, capped)) => Some(wrapper(
264 BlobKind::Snappy,
265 Confidence::High,
266 format!(
267 "Snappy framed stream; {} bytes decompressed{}",
268 data.len(),
269 if capped { " (capped at limit)" } else { "" }
270 ),
271 build_chain(&data, capped, limits, depth),
272 )),
273 Err(e) => Some(leaf(
274 BlobKind::Snappy,
275 Confidence::Medium,
276 format!("Snappy magic but decompression failed: {e}"),
277 )),
278 }
279}
280
281fn detect_json(bytes: &[u8]) -> Option<Candidate> {
282 let trimmed = bytes.trim_ascii();
283 if !matches!(trimmed.first(), Some(b'{' | b'[')) {
286 return None;
287 }
288 let value: serde_json::Value = serde_json::from_slice(trimmed).ok()?;
289 Some(leaf(
290 BlobKind::Json,
291 Confidence::High,
292 describe_json(&value),
293 ))
294}
295
296fn detect_uuid_string(bytes: &[u8]) -> Option<Candidate> {
297 let s = std::str::from_utf8(bytes).ok()?.trim();
298 if !s.contains('-') {
301 return None;
302 }
303 let u = uuid::Uuid::try_parse(s).ok()?;
304 Some(leaf(
305 BlobKind::Uuid,
306 Confidence::High,
307 format!(
308 "UUID {u} (version {}, variant {:?})",
309 u.get_version_num(),
310 u.get_variant()
311 ),
312 ))
313}
314
315fn detect_v8_blink(bytes: &[u8]) -> Option<Candidate> {
326 if bytes.first() != Some(&0xFF) || bytes.len() < 3 {
327 return None;
328 }
329 let is_blink = matches!(bytes.get(2), Some(0xFE | 0xFF));
330 let (kind, result) = if is_blink {
331 (
332 BlobKind::BlinkSerialized,
333 v8_value::deserialize_blink(bytes),
334 )
335 } else {
336 (BlobKind::V8Serialized, v8_value::deserialize(bytes))
337 };
338 match result {
339 Ok(v) => Some(leaf(
340 kind,
341 Confidence::High,
342 format!("{}: {}", kind.label(), v.summary()),
343 )),
344 Err(e) => {
345 let opens_as_v8 = bytes
349 .get(1)
350 .zip(bytes.get(2))
351 .is_some_and(|(_ver, &t)| v8_value::is_value_tag(t) || t == 0xFE || t == 0xFF);
352 if opens_as_v8 {
353 Some(leaf(
354 kind,
355 Confidence::Medium,
356 format!("{} header but decode failed: {e}", kind.label()),
357 ))
358 } else {
359 None
360 }
361 }
362 }
363}
364
365fn detect_base64(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
371 let decoded = try_base64(bytes)?;
372 let chain = build_chain(&decoded, false, limits, depth);
373 let score = wrapper_score(chain.best.kind);
374 Some(wrapper(
375 BlobKind::Base64,
376 score,
377 format!("base64 text; decodes to {} bytes", chain.decoded_len),
378 chain,
379 ))
380}
381
382fn detect_hex(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
383 let s = bytes.trim_ascii();
384 if s.len() < 4 || !s.len().is_multiple_of(2) || !s.iter().all(u8::is_ascii_hexdigit) {
385 return None;
386 }
387 let decoded = hex::decode(s).ok()?;
388 let chain = build_chain(&decoded, false, limits, depth);
389 let score = wrapper_score(chain.best.kind);
390 Some(wrapper(
391 BlobKind::Hex,
392 score,
393 format!("hexadecimal text; decodes to {} bytes", chain.decoded_len),
394 chain,
395 ))
396}
397
398fn detect_uuid_bytes(bytes: &[u8]) -> Option<Candidate> {
399 let arr: [u8; 16] = bytes.try_into().ok()?;
400 let u = uuid::Uuid::from_bytes(arr);
401 Some(leaf(
402 BlobKind::Uuid,
403 Confidence::Low,
405 format!("if a UUID: {u} (note: any 16 bytes form a valid UUID)"),
406 ))
407}
408
409fn detect_utf16le(bytes: &[u8]) -> Option<Candidate> {
410 if bytes.len() < 4 || !bytes.len().is_multiple_of(2) {
411 return None;
412 }
413 let has_bom = bytes.starts_with(&[0xff, 0xfe]);
414 let body = if has_bom { &bytes[2..] } else { bytes };
415 let units: Vec<u16> = body
416 .chunks_exact(2)
417 .map(|c| u16::from_le_bytes([c[0], c[1]]))
418 .collect();
419 if units.is_empty() {
420 return None;
421 }
422 let text = String::from_utf16(&units).ok()?;
423 if !mostly_printable(&text) {
424 return None;
425 }
426 let ascii_plane = body.chunks_exact(2).filter(|c| c[1] == 0).count();
429 if !has_bom && (ascii_plane * 2) < units.len() {
430 return None;
431 }
432 Some(leaf(
433 BlobKind::Utf16Le,
434 if has_bom {
435 Confidence::Medium
436 } else {
437 Confidence::Low
438 },
439 format!("UTF-16LE text preview: \"{}\"", preview(&text)),
440 ))
441}
442
443fn detect_utf8_text(bytes: &[u8]) -> Option<Candidate> {
444 let s = std::str::from_utf8(bytes).ok()?;
445 if s.is_empty() || !mostly_printable(s) {
446 return None;
447 }
448 Some(leaf(
449 BlobKind::Utf8Text,
450 Confidence::Low,
451 format!("UTF-8 text preview: \"{}\"", preview(s)),
452 ))
453}
454
455fn detect_protobuf(bytes: &[u8], strong_present: bool) -> Option<Candidate> {
467 let fields = protobuf_forensic_core::decode(bytes).ok()?;
468 if fields.is_empty() {
469 return None;
470 }
471 let (submessages, strings) = count_structure(&fields);
472 let structured = submessages > 0 || strings > 0;
473 let score = if structured && !strong_present {
474 Confidence::Medium
475 } else {
476 Confidence::Low
477 };
478 Some(leaf(
479 BlobKind::Protobuf,
480 score,
481 format!(
482 "protobuf wire-format message: {} field{} ({submessages} submessage{}, {strings} string{})",
483 fields.len(),
484 plural(fields.len()),
485 plural(submessages),
486 plural(strings),
487 ),
488 ))
489}
490
491fn count_structure(fields: &[protobuf_forensic_core::Field]) -> (usize, usize) {
495 let mut submessages = 0;
496 let mut strings = 0;
497 for f in fields {
498 if let FieldValue::Len(lv) = &f.value {
499 match lv.interp {
500 LenInterp::Message(_) => submessages += 1,
501 LenInterp::Text(_) => strings += 1,
502 LenInterp::Bytes => {}
503 }
504 }
505 }
506 (submessages, strings)
507}
508
509fn plural(n: usize) -> &'static str {
510 if n == 1 {
511 ""
512 } else {
513 "s"
514 }
515}
516
517fn wrapper_score(inner: BlobKind) -> Confidence {
525 match inner {
526 BlobKind::Unknown | BlobKind::Utf8Text | BlobKind::Utf16Le => Confidence::Low,
527 _ => Confidence::Medium,
528 }
529}
530
531fn leaf(kind: BlobKind, score: Confidence, summary: String) -> Candidate {
532 Candidate {
533 kind,
534 score,
535 summary,
536 citation: kind.citation(),
537 inner: None,
538 }
539}
540
541fn wrapper(kind: BlobKind, score: Confidence, summary: String, chain: DecodedChain) -> Candidate {
542 Candidate {
543 kind,
544 score,
545 summary,
546 citation: kind.citation(),
547 inner: Some(Box::new(chain)),
548 }
549}
550
551fn unknown(bytes: &[u8]) -> Candidate {
552 Candidate {
553 kind: BlobKind::Unknown,
554 score: Confidence::Low,
555 summary: if bytes.is_empty() {
556 "unrecognized: empty input".to_owned()
557 } else {
558 format!(
559 "unrecognized; {} bytes (head: {})",
560 bytes.len(),
561 head_hex(bytes)
562 )
563 },
564 citation: BlobKind::Unknown.citation(),
565 inner: None,
566 }
567}
568
569fn try_base64(bytes: &[u8]) -> Option<Vec<u8>> {
573 let cleaned: Vec<u8> = bytes
574 .iter()
575 .copied()
576 .filter(|b| !b.is_ascii_whitespace())
577 .collect();
578 if cleaned.len() < 8 || !cleaned.len().is_multiple_of(4) {
579 return None;
580 }
581 let eq = cleaned
582 .iter()
583 .position(|&b| b == b'=')
584 .unwrap_or(cleaned.len());
585 let (body, padding) = cleaned.split_at(eq);
586 if padding.len() > 2 || padding.iter().any(|&b| b != b'=') || body.is_empty() {
587 return None;
588 }
589 let is_std = body
590 .iter()
591 .all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
592 let is_url = body
593 .iter()
594 .all(|&b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
595 if is_std {
596 base64::engine::general_purpose::STANDARD
597 .decode(&cleaned)
598 .ok()
599 } else if is_url {
600 base64::engine::general_purpose::URL_SAFE
601 .decode(&cleaned)
602 .ok()
603 } else {
604 None
605 }
606}
607
608fn describe_plist(v: &plist::Value) -> String {
609 match v {
610 plist::Value::Array(a) => format!("array with {} items", a.len()),
611 plist::Value::Dictionary(d) => format!("dict with {} entries", d.len()),
612 plist::Value::Boolean(_) => "boolean".to_owned(),
613 plist::Value::Data(d) => format!("data ({} bytes)", d.len()),
614 plist::Value::Date(_) => "date".to_owned(),
615 plist::Value::Real(_) => "real".to_owned(),
616 plist::Value::Integer(_) => "integer".to_owned(),
617 plist::Value::String(_) => "string".to_owned(),
618 plist::Value::Uid(_) => "uid".to_owned(),
619 _ => "value".to_owned(),
620 }
621}
622
623fn describe_json(v: &serde_json::Value) -> String {
624 match v {
625 serde_json::Value::Object(m) => format!("JSON object with {} keys", m.len()),
626 serde_json::Value::Array(a) => format!("JSON array with {} elements", a.len()),
627 _ => "JSON value".to_owned(),
629 }
630}
631
632fn contains(haystack: &[u8], needle: &[u8]) -> bool {
633 haystack.windows(needle.len()).any(|w| w == needle)
634}
635
636fn mostly_printable(s: &str) -> bool {
637 let total = s.chars().count();
638 if total == 0 {
639 return false;
640 }
641 let printable = s
642 .chars()
643 .filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
644 .count();
645 (printable * 100) >= (total * 90)
646}
647
648fn head_hex(bytes: &[u8]) -> String {
649 let n = bytes.len().min(16);
650 let mut s = hex::encode(&bytes[..n]);
651 if bytes.len() > n {
652 s = format!("{s} (+{} more)", bytes.len() - n);
653 }
654 s
655}
656
657fn preview(s: &str) -> String {
658 const MAX: usize = 48;
659 let flat: String = s
660 .chars()
661 .map(|c| if c.is_control() { ' ' } else { c })
662 .collect();
663 if flat.chars().count() <= MAX {
664 flat
665 } else {
666 let cut: String = flat.chars().take(MAX).collect();
667 format!("{cut}… ({} chars total)", s.chars().count())
668 }
669}