use std::io::{Cursor, Read};
use base64::Engine as _;
use protobuf_forensic_core::{FieldValue, LenInterp};
use crate::{BlobKind, Candidate, Confidence, DecodedChain, Limits};
#[must_use]
pub fn identify(bytes: &[u8]) -> Vec<Candidate> {
identify_with_limits(bytes, Limits::default(), 0)
}
#[must_use]
pub fn identify_with_limits(bytes: &[u8], limits: Limits, depth: usize) -> Vec<Candidate> {
let mut out: Vec<Candidate> = Vec::new();
push(&mut out, detect_binary_plist(bytes));
push(&mut out, detect_xml_plist(bytes));
push(&mut out, detect_gzip(bytes, limits, depth));
push(&mut out, detect_zlib(bytes, limits, depth));
push(&mut out, detect_snappy(bytes, limits, depth));
push(&mut out, detect_json(bytes));
push(&mut out, detect_uuid_string(bytes));
if bytes.len() <= limits.max_input {
push(&mut out, detect_base64(bytes, limits, depth));
push(&mut out, detect_hex(bytes, limits, depth));
push(&mut out, detect_uuid_bytes(bytes));
push(&mut out, detect_utf16le(bytes));
push(&mut out, detect_utf8_text(bytes));
let strong = out.iter().any(|c| c.score == Confidence::High);
push(&mut out, detect_protobuf(bytes, strong));
}
if out.is_empty() {
out.push(unknown(bytes));
}
out.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| kind_rank(b.kind).cmp(&kind_rank(a.kind)))
.then_with(|| a.kind.label().cmp(b.kind.label()))
});
out
}
fn push(out: &mut Vec<Candidate>, c: Option<Candidate>) {
if let Some(c) = c {
out.push(c);
}
}
fn kind_rank(kind: BlobKind) -> u8 {
match kind {
BlobKind::BinaryPlist
| BlobKind::XmlPlist
| BlobKind::Gzip
| BlobKind::Zlib
| BlobKind::Snappy
| BlobKind::Json
| BlobKind::Uuid => 3,
BlobKind::Base64 | BlobKind::Hex => 2,
BlobKind::Protobuf | BlobKind::Utf16Le | BlobKind::Utf8Text => 1,
BlobKind::Unknown => 0,
}
}
fn build_chain(data: &[u8], capped: bool, limits: Limits, depth: usize) -> DecodedChain {
let best = if depth + 1 >= limits.max_depth {
Box::new(depth_capped(data))
} else {
identify_with_limits(data, limits, depth + 1)
.into_iter()
.next()
.map_or_else(|| Box::new(unknown(data)), Box::new)
};
DecodedChain {
decoded_len: data.len(),
capped,
best,
}
}
fn depth_capped(data: &[u8]) -> Candidate {
Candidate {
kind: BlobKind::Unknown,
score: Confidence::Low,
summary: format!(
"recursion depth cap reached; {} bytes not further decoded (head: {})",
data.len(),
head_hex(data)
),
citation: BlobKind::Unknown.citation(),
inner: None,
}
}
fn bounded_read<R: Read>(r: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)> {
let mut out = Vec::new();
r.take(cap as u64 + 1).read_to_end(&mut out)?;
let capped = out.len() > cap;
if capped {
out.truncate(cap);
}
Ok((out, capped))
}
fn detect_binary_plist(bytes: &[u8]) -> Option<Candidate> {
if !bytes.starts_with(b"bplist") {
return None;
}
Some(match plist::Value::from_reader(Cursor::new(bytes)) {
Ok(v) => leaf(
BlobKind::BinaryPlist,
Confidence::High,
format!("binary plist: {}", describe_plist(&v)),
),
Err(e) => leaf(
BlobKind::BinaryPlist,
Confidence::Medium,
format!("bplist magic but parse failed: {e}"),
),
})
}
fn detect_xml_plist(bytes: &[u8]) -> Option<Candidate> {
let head = bytes.trim_ascii_start();
let probe = &head[..head.len().min(1024)];
let starts_xml = probe.starts_with(b"<?xml")
|| probe.starts_with(b"<plist")
|| probe.starts_with(b"<!DOCTYPE");
if !starts_xml || !contains(probe, b"plist") {
return None;
}
Some(match plist::Value::from_reader(Cursor::new(bytes)) {
Ok(v) => leaf(
BlobKind::XmlPlist,
Confidence::High,
format!("XML plist: {}", describe_plist(&v)),
),
Err(e) => leaf(
BlobKind::XmlPlist,
Confidence::Medium,
format!("XML plist markup but parse failed: {e}"),
),
})
}
fn detect_gzip(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
if !bytes.starts_with(&[0x1f, 0x8b]) {
return None;
}
match bounded_read(flate2::read::GzDecoder::new(bytes), limits.max_output) {
Ok((data, capped)) => Some(wrapper(
BlobKind::Gzip,
Confidence::High,
format!(
"gzip stream; {} bytes decompressed{}",
data.len(),
if capped { " (capped at limit)" } else { "" }
),
build_chain(&data, capped, limits, depth),
)),
Err(e) => Some(leaf(
BlobKind::Gzip,
Confidence::Medium,
format!("gzip magic but decompression failed: {e}"),
)),
}
}
fn detect_zlib(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
if bytes.len() < 2 {
return None;
}
let (cmf, flg) = (bytes[0], bytes[1]);
if cmf & 0x0f != 0x08 || cmf >> 4 > 7 {
return None;
}
if !((u16::from(cmf) << 8) | u16::from(flg)).is_multiple_of(31) {
return None;
}
let (data, capped) =
bounded_read(flate2::read::ZlibDecoder::new(bytes), limits.max_output).ok()?;
Some(wrapper(
BlobKind::Zlib,
Confidence::High,
format!(
"zlib stream; {} bytes decompressed{}",
data.len(),
if capped { " (capped at limit)" } else { "" }
),
build_chain(&data, capped, limits, depth),
))
}
fn detect_snappy(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
const MAGIC: &[u8] = &[0xff, 0x06, 0x00, 0x00, 0x73, 0x4e, 0x61, 0x50, 0x70, 0x59];
if !bytes.starts_with(MAGIC) {
return None;
}
match bounded_read(snap::read::FrameDecoder::new(bytes), limits.max_output) {
Ok((data, capped)) => Some(wrapper(
BlobKind::Snappy,
Confidence::High,
format!(
"Snappy framed stream; {} bytes decompressed{}",
data.len(),
if capped { " (capped at limit)" } else { "" }
),
build_chain(&data, capped, limits, depth),
)),
Err(e) => Some(leaf(
BlobKind::Snappy,
Confidence::Medium,
format!("Snappy magic but decompression failed: {e}"),
)),
}
}
fn detect_json(bytes: &[u8]) -> Option<Candidate> {
let trimmed = bytes.trim_ascii();
if !matches!(trimmed.first(), Some(b'{' | b'[')) {
return None;
}
let value: serde_json::Value = serde_json::from_slice(trimmed).ok()?;
Some(leaf(
BlobKind::Json,
Confidence::High,
describe_json(&value),
))
}
fn detect_uuid_string(bytes: &[u8]) -> Option<Candidate> {
let s = std::str::from_utf8(bytes).ok()?.trim();
if !s.contains('-') {
return None;
}
let u = uuid::Uuid::try_parse(s).ok()?;
Some(leaf(
BlobKind::Uuid,
Confidence::High,
format!(
"UUID {u} (version {}, variant {:?})",
u.get_version_num(),
u.get_variant()
),
))
}
fn detect_base64(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
let decoded = try_base64(bytes)?;
let chain = build_chain(&decoded, false, limits, depth);
let score = wrapper_score(chain.best.kind);
Some(wrapper(
BlobKind::Base64,
score,
format!("base64 text; decodes to {} bytes", chain.decoded_len),
chain,
))
}
fn detect_hex(bytes: &[u8], limits: Limits, depth: usize) -> Option<Candidate> {
let s = bytes.trim_ascii();
if s.len() < 4 || !s.len().is_multiple_of(2) || !s.iter().all(u8::is_ascii_hexdigit) {
return None;
}
let decoded = hex::decode(s).ok()?;
let chain = build_chain(&decoded, false, limits, depth);
let score = wrapper_score(chain.best.kind);
Some(wrapper(
BlobKind::Hex,
score,
format!("hexadecimal text; decodes to {} bytes", chain.decoded_len),
chain,
))
}
fn detect_uuid_bytes(bytes: &[u8]) -> Option<Candidate> {
let arr: [u8; 16] = bytes.try_into().ok()?;
let u = uuid::Uuid::from_bytes(arr);
Some(leaf(
BlobKind::Uuid,
Confidence::Low,
format!("if a UUID: {u} (note: any 16 bytes form a valid UUID)"),
))
}
fn detect_utf16le(bytes: &[u8]) -> Option<Candidate> {
if bytes.len() < 4 || !bytes.len().is_multiple_of(2) {
return None;
}
let has_bom = bytes.starts_with(&[0xff, 0xfe]);
let body = if has_bom { &bytes[2..] } else { bytes };
let units: Vec<u16> = body
.chunks_exact(2)
.map(|c| u16::from_le_bytes([c[0], c[1]]))
.collect();
if units.is_empty() {
return None;
}
let text = String::from_utf16(&units).ok()?;
if !mostly_printable(&text) {
return None;
}
let ascii_plane = body.chunks_exact(2).filter(|c| c[1] == 0).count();
if !has_bom && (ascii_plane * 2) < units.len() {
return None;
}
Some(leaf(
BlobKind::Utf16Le,
if has_bom {
Confidence::Medium
} else {
Confidence::Low
},
format!("UTF-16LE text preview: \"{}\"", preview(&text)),
))
}
fn detect_utf8_text(bytes: &[u8]) -> Option<Candidate> {
let s = std::str::from_utf8(bytes).ok()?;
if s.is_empty() || !mostly_printable(s) {
return None;
}
Some(leaf(
BlobKind::Utf8Text,
Confidence::Low,
format!("UTF-8 text preview: \"{}\"", preview(s)),
))
}
fn detect_protobuf(bytes: &[u8], strong_present: bool) -> Option<Candidate> {
let fields = protobuf_forensic_core::decode(bytes).ok()?;
if fields.is_empty() {
return None;
}
let (submessages, strings) = count_structure(&fields);
let structured = submessages > 0 || strings > 0;
let score = if structured && !strong_present {
Confidence::Medium
} else {
Confidence::Low
};
Some(leaf(
BlobKind::Protobuf,
score,
format!(
"protobuf wire-format message: {} field{} ({submessages} submessage{}, {strings} string{})",
fields.len(),
plural(fields.len()),
plural(submessages),
plural(strings),
),
))
}
fn count_structure(fields: &[protobuf_forensic_core::Field]) -> (usize, usize) {
let mut submessages = 0;
let mut strings = 0;
for f in fields {
if let FieldValue::Len(lv) = &f.value {
match lv.interp {
LenInterp::Message(_) => submessages += 1,
LenInterp::Text(_) => strings += 1,
LenInterp::Bytes => {}
}
}
}
(submessages, strings)
}
fn plural(n: usize) -> &'static str {
if n == 1 {
""
} else {
"s"
}
}
fn wrapper_score(inner: BlobKind) -> Confidence {
match inner {
BlobKind::Unknown | BlobKind::Utf8Text | BlobKind::Utf16Le => Confidence::Low,
_ => Confidence::Medium,
}
}
fn leaf(kind: BlobKind, score: Confidence, summary: String) -> Candidate {
Candidate {
kind,
score,
summary,
citation: kind.citation(),
inner: None,
}
}
fn wrapper(kind: BlobKind, score: Confidence, summary: String, chain: DecodedChain) -> Candidate {
Candidate {
kind,
score,
summary,
citation: kind.citation(),
inner: Some(Box::new(chain)),
}
}
fn unknown(bytes: &[u8]) -> Candidate {
Candidate {
kind: BlobKind::Unknown,
score: Confidence::Low,
summary: if bytes.is_empty() {
"unrecognized: empty input".to_owned()
} else {
format!(
"unrecognized; {} bytes (head: {})",
bytes.len(),
head_hex(bytes)
)
},
citation: BlobKind::Unknown.citation(),
inner: None,
}
}
fn try_base64(bytes: &[u8]) -> Option<Vec<u8>> {
let cleaned: Vec<u8> = bytes
.iter()
.copied()
.filter(|b| !b.is_ascii_whitespace())
.collect();
if cleaned.len() < 8 || !cleaned.len().is_multiple_of(4) {
return None;
}
let eq = cleaned
.iter()
.position(|&b| b == b'=')
.unwrap_or(cleaned.len());
let (body, padding) = cleaned.split_at(eq);
if padding.len() > 2 || padding.iter().any(|&b| b != b'=') || body.is_empty() {
return None;
}
let is_std = body
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/');
let is_url = body
.iter()
.all(|&b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_');
if is_std {
base64::engine::general_purpose::STANDARD
.decode(&cleaned)
.ok()
} else if is_url {
base64::engine::general_purpose::URL_SAFE
.decode(&cleaned)
.ok()
} else {
None
}
}
fn describe_plist(v: &plist::Value) -> String {
match v {
plist::Value::Array(a) => format!("array with {} items", a.len()),
plist::Value::Dictionary(d) => format!("dict with {} entries", d.len()),
plist::Value::Boolean(_) => "boolean".to_owned(),
plist::Value::Data(d) => format!("data ({} bytes)", d.len()),
plist::Value::Date(_) => "date".to_owned(),
plist::Value::Real(_) => "real".to_owned(),
plist::Value::Integer(_) => "integer".to_owned(),
plist::Value::String(_) => "string".to_owned(),
plist::Value::Uid(_) => "uid".to_owned(),
_ => "value".to_owned(),
}
}
fn describe_json(v: &serde_json::Value) -> String {
match v {
serde_json::Value::Object(m) => format!("JSON object with {} keys", m.len()),
serde_json::Value::Array(a) => format!("JSON array with {} elements", a.len()),
_ => "JSON value".to_owned(),
}
}
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
haystack.windows(needle.len()).any(|w| w == needle)
}
fn mostly_printable(s: &str) -> bool {
let total = s.chars().count();
if total == 0 {
return false;
}
let printable = s
.chars()
.filter(|c| !c.is_control() || matches!(c, '\t' | '\n' | '\r'))
.count();
(printable * 100) >= (total * 90)
}
fn head_hex(bytes: &[u8]) -> String {
let n = bytes.len().min(16);
let mut s = hex::encode(&bytes[..n]);
if bytes.len() > n {
s = format!("{s} (+{} more)", bytes.len() - n);
}
s
}
fn preview(s: &str) -> String {
const MAX: usize = 48;
let flat: String = s
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
if flat.chars().count() <= MAX {
flat
} else {
let cut: String = flat.chars().take(MAX).collect();
format!("{cut}… ({} chars total)", s.chars().count())
}
}