Skip to main content

sqlite_forensic/
interpret.rs

1//! The blob-interpreter seam (roadmap §4.5 follow-up): a dependency-inversion
2//! contract so consumers can decode opaque `BLOB` values in schema context —
3//! `WebKit` Local Storage UTF-16, or the general [`blob-decoder`] crate (plist /
4//! gzip / JSON / …) — WITHOUT this reader library taking on any decode dependency
5//! or MSRV cost.
6//!
7//! [`blob-decoder`]: https://crates.io/crates/blob-decoder
8//!
9//! The library owns the *contract* + the *schema context* ([`BlobContext`]); a
10//! consumer supplies the *decoder* by implementing [`BlobInterpreter`]. Passing
11//! `None` is the default: unchanged behaviour, the raw bytes are surfaced as-is.
12//!
13//! Secure by design: [`Interpretation::lossy`] is a struct field, so a caller
14//! cannot render a lossy decode as a faithful one — the uncertainty is structural,
15//! not a side-channel warning.
16
17use sqlite_core::{decode_localstorage_value, is_local_storage_item_table, Value};
18
19/// The schema context a [`BlobInterpreter`] may use as a decoding prior — e.g. a
20/// known table/column ("this column holds UTF-16 Local Storage values") lifts a
21/// structural, low-confidence reading to a high-confidence one.
22#[derive(Debug, Clone, Copy, Default)]
23pub struct BlobContext<'a> {
24    /// The table the record was attributed to, when known.
25    pub table: Option<&'a str>,
26    /// The column the value came from, when known.
27    pub column: Option<&'a str>,
28}
29
30/// A consumer-supplied decoding of an opaque `BLOB` value. The observation of what
31/// the bytes *are*, never a guarantee — [`lossy`](Self::lossy) records when the
32/// decode dropped or substituted data.
33#[derive(Debug, Clone, PartialEq)]
34pub struct Interpretation {
35    /// The decoded, human-readable form of the value.
36    pub text: String,
37    /// A short label for the reading (e.g. `utf-16`, `bplist`, `gzip`).
38    pub kind: String,
39    /// Whether the decode was lossy (an unpaired surrogate, a truncated payload,
40    /// bytes substituted with U+FFFD). Structural, so a consumer cannot present a
41    /// lossy decode as faithful.
42    pub lossy: bool,
43    /// Heuristic confidence in `(0.0, 1.0]` that this reading is correct.
44    pub confidence: f32,
45}
46
47/// A consumer-supplied interpreter of opaque `BLOB` values. Implemented OUTSIDE
48/// this crate (a `blob-decoder` adapter, or the built-in Local Storage decoder) so
49/// the reader library carries no decode dependency.
50pub trait BlobInterpreter {
51    /// Interpret a `BLOB`'s bytes in the given schema context, or `None` when this
52    /// interpreter recognises nothing in them.
53    fn interpret(&self, bytes: &[u8], ctx: &BlobContext<'_>) -> Option<Interpretation>;
54}
55
56/// Confidence assigned when a BLOB is decoded under a matching schema-name prior
57/// (a `WebKit` Local Storage `ItemTable` column): the context makes UTF-16-LE the
58/// confident reading, not a coincidental structural match.
59const SCHEMA_MATCHED_CONFIDENCE: f32 = 0.95;
60
61/// The built-in, dependency-free [`BlobInterpreter`] for `WebKit`/Chromium Local
62/// Storage. A `.localstorage` `ItemTable.value` BLOB holds its string as raw
63/// UTF-16-LE (no BOM, no type byte); this decodes it — but ONLY when the schema
64/// context identifies the `ItemTable` column, which is the prior that makes the
65/// UTF-16-LE reading confident. An arbitrary BLOB is not this interpreter's job
66/// (that is the general `blob-decoder` adapter); it returns `None`.
67#[derive(Debug, Clone, Copy, Default)]
68pub struct LocalStorageInterpreter;
69
70impl BlobInterpreter for LocalStorageInterpreter {
71    fn interpret(&self, bytes: &[u8], ctx: &BlobContext<'_>) -> Option<Interpretation> {
72        // Fire only under the ItemTable schema-name prior — without it there is no
73        // evidence these bytes are UTF-16-LE rather than any other encoding.
74        if !ctx.table.is_some_and(is_local_storage_item_table) {
75            return None;
76        }
77        let decoded = decode_localstorage_value(bytes);
78        Some(Interpretation {
79            text: decoded.text,
80            kind: "utf-16le".to_string(),
81            lossy: decoded.lossy,
82            confidence: SCHEMA_MATCHED_CONFIDENCE,
83        })
84    }
85}
86
87/// Apply `interpreter` to every `BLOB` in `values`, returning `(column_index,
88/// interpretation)` for each blob the interpreter recognised. Non-blob values are
89/// skipped. `interpreter == None` yields an empty result — the default, unchanged
90/// behaviour, so this passthrough is non-breaking for every existing caller.
91#[must_use]
92pub fn interpret_values(
93    values: &[Value],
94    ctx: &BlobContext<'_>,
95    interpreter: Option<&dyn BlobInterpreter>,
96) -> Vec<(usize, Interpretation)> {
97    let Some(interpreter) = interpreter else {
98        return Vec::new();
99    };
100    values
101        .iter()
102        .enumerate()
103        .filter_map(|(i, value)| match value {
104            Value::Blob(bytes) => interpreter.interpret(bytes, ctx).map(|interp| (i, interp)),
105            _ => None,
106        })
107        .collect()
108}