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::{v8_value, 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    push(&mut out, detect_v8_blink(bytes));
41
42    // Heuristic detectors are bounded by max_input (they scan / decode the whole
43    // blob), so huge inputs get magic-only identification.
44    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        // Protobuf is a *permissive* wire format — many random/other-format byte
51        // strings parse cleanly — so it runs last and is downgraded to Low when
52        // any High-confidence kind already matched (it is never the true reading
53        // of a gzip/plist/json blob).
54        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
77/// Specificity tiebreak when two candidates share a [`Confidence`]: a concrete
78/// magic-identified type outranks a generic wrapper, which outranks bare text.
79fn 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        // Protobuf sits at the bottom heuristic tier with bare text: a successful
92        // parse is a weak signal, so it never wins a confidence tie against a
93        // magic-identified (rank-3) or decoded-wrapper (rank-2) kind.
94        BlobKind::Protobuf | BlobKind::Utf16Le | BlobKind::Utf8Text => 1,
95        BlobKind::Unknown => 0,
96    }
97}
98
99// ---------------------------------------------------------------------------
100// Wrapper payload recursion
101// ---------------------------------------------------------------------------
102
103/// Build the [`DecodedChain`] for a wrapper's decoded payload: recurse to
104/// identify it, unless the depth cap is reached (the DoS backstop for
105/// infinitely-nested wrappers).
106fn 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            // cov:unreachable: identify_with_limits never returns empty (pushes Unknown), kept defensive fallback.
114            .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
137// ---------------------------------------------------------------------------
138// Bounded decompression (the decompression-bomb guard)
139// ---------------------------------------------------------------------------
140
141/// Read at most `cap` bytes from `r`; return the bytes and whether the stream
142/// was *capped* (had more to give). Bounds memory to `cap`, so a decompression
143/// bomb can never exhaust it.
144fn bounded_read<R: Read>(r: R, cap: usize) -> std::io::Result<(Vec<u8>, bool)> {
145    let mut out = Vec::new();
146    // take(cap+1): if we get cap+1 bytes the stream had more → it was capped.
147    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
155// ---------------------------------------------------------------------------
156// Detectors — strong magic / full parse
157// ---------------------------------------------------------------------------
158
159fn 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    // Bounded prefix scan for the plist markers, so a huge non-plist XML body is
180    // rejected cheaply. Require the `plist` token to distinguish it from any XML.
181    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    // RFC 1952: magic 1f 8b. A 2-byte magic is specific enough that a decode
204    // failure is still worth reporting (as a truncated/corrupt gzip).
205    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    // RFC 1950 header has no unique magic — only CM=deflate + the FCHECK mod-31
229    // constraint (a weak ~1/500 filter). So we claim zlib ONLY on a SUCCESSFUL
230    // decompress; a header match that fails to inflate is treated as coincidence
231    // (returns None), never a false Medium on random bytes.
232    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    // Snappy framing format: the stream identifier chunk (0xff + "sNaPpY").
258    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    // Only object/array roots are claimed as JSON: a bare `123` or `true` is
284    // technically JSON but too ambiguous to assert.
285    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    // Require the hyphenated (or braced/urn) canonical form: a bare 32-hex run is
299    // better read as hex, so we do not claim it here.
300    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
315/// Detect a V8 structured-clone value or a Blink `SerializedScriptValue`. Both
316/// open with the `0xFF` version tag; a Blink envelope is distinguished by a
317/// framing byte (`0xFE` trailer or a nested `0xFF` V8 header) in the third
318/// position, where a raw V8 stream carries a value tag instead.
319///
320/// A full decode is High confidence. A decode that fails *after* opening a valid
321/// V8 value (e.g. a `kHostObject` typed array node serializes, or a truncated
322/// stream) is Medium and fails loud — surfacing the tag — rather than fabricating
323/// a reading. A `0xFF`-led blob whose first value tag is not a V8 tag is not
324/// claimed at all (returns `None`).
325fn 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            // Only report a failed decode when the payload actually opened as V8
346            // (a known value tag right after the version header) — otherwise a
347            // random 0xFF-led binary would be mislabelled.
348            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
365// ---------------------------------------------------------------------------
366// Detectors — heuristic (coincidence-prone, scored Low unless the payload is
367// itself recognised)
368// ---------------------------------------------------------------------------
369
370fn 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        // Any 16 bytes form a syntactically valid UUID — never over-claim.
404        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    // Without a BOM, require the ASCII-plane dominance that real UTF-16LE text
427    // shows (high byte zero), else even-length binary would masquerade as text.
428    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
455/// Attempt a schemaless protobuf decode (delegated to `protobuf-forensic-core`).
456/// A candidate is offered ONLY when the whole input decodes as a valid message
457/// with ≥1 field — no trailing garbage, no empty message.
458///
459/// Scoring is deliberately conservative: protobuf is a *permissive* wire format
460/// (a large fraction of arbitrary byte strings parse as "valid protobuf"), so a
461/// bare successful parse is a WEAK signal and scores [`Confidence::Low`]. It is
462/// lifted to [`Confidence::Medium`] only on a corroborating signal — the message
463/// carries a nested submessage or a string field (structure a coincidental parse
464/// rarely produces) AND no High-confidence kind already matched (`strong_present`),
465/// so a gzip/plist/json blob that also happens to parse never gets a Medium.
466fn 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
491/// Count the top-level length-delimited fields inferred as nested submessages and
492/// as strings — the corroborating structure that distinguishes a plausibly-real
493/// message from a coincidental parse of opaque scalars.
494fn 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
517// ---------------------------------------------------------------------------
518// Scoring / construction helpers
519// ---------------------------------------------------------------------------
520
521/// A wrapper (base64/hex) whose decoded payload is itself a concrete recognised
522/// type is Medium; one that decodes only to opaque bytes or plain text is Low
523/// (the decode was probably coincidental).
524fn 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
569/// Validate + decode base64 (standard or URL-safe, whitespace-tolerant). Returns
570/// the decoded bytes, or `None` if the input is not clean, correctly-padded
571/// base64 — delegating the decode itself to the `base64` crate.
572fn 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        // Unreachable: detect_json only enters on `{`/`[` roots.
628        _ => "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}