1use 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#[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
41 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 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
76fn 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 BlobKind::Protobuf | BlobKind::Utf16Le | BlobKind::Utf8Text => 1,
92 BlobKind::Unknown => 0,
93 }
94}
95
96fn 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 .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
134fn bounded_read<R: Read>(r: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)> {
142 let mut out = Vec::new();
143 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
152fn 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 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 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 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 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 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 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
312fn 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 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 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
402fn 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
438fn 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
464fn 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
516fn 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 _ => "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}