diff --git a/forensic/src/interpret.rs b/forensic/src/interpret.rs new file mode 100644 index 0000000..5088bd2 --- /dev/null +++ b/forensic/src/interpret.rs @@ -0,0 +1,108 @@ +//! The blob-interpreter seam (roadmap §4.5 follow-up): a dependency-inversion +//! contract so consumers can decode opaque `BLOB` values in schema context — +//! `WebKit` Local Storage UTF-16, or the general [`blob-decoder`] crate (plist / +//! gzip / JSON / …) — WITHOUT this reader library taking on any decode dependency +//! or MSRV cost. +//! +//! [`blob-decoder`]: https://crates.io/crates/blob-decoder +//! +//! The library owns the *contract* + the *schema context* ([`BlobContext`]); a +//! consumer supplies the *decoder* by implementing [`BlobInterpreter`]. Passing +//! `None` is the default: unchanged behaviour, the raw bytes are surfaced as-is. +//! +//! Secure by design: [`Interpretation::lossy`] is a struct field, so a caller +//! cannot render a lossy decode as a faithful one — the uncertainty is structural, +//! not a side-channel warning. + +use sqlite_core::{decode_localstorage_value, is_local_storage_item_table, Value}; + +/// The schema context a [`BlobInterpreter`] may use as a decoding prior — e.g. a +/// known table/column ("this column holds UTF-16 Local Storage values") lifts a +/// structural, low-confidence reading to a high-confidence one. +#[derive(Debug, Clone, Copy, Default)] +pub struct BlobContext<'a> { + /// The table the record was attributed to, when known. + pub table: Option<&'a str>, + /// The column the value came from, when known. + pub column: Option<&'a str>, +} + +/// A consumer-supplied decoding of an opaque `BLOB` value. The observation of what +/// the bytes *are*, never a guarantee — [`lossy`](Self::lossy) records when the +/// decode dropped or substituted data. +#[derive(Debug, Clone, PartialEq)] +pub struct Interpretation { + /// The decoded, human-readable form of the value. + pub text: String, + /// A short label for the reading (e.g. `utf-16`, `bplist`, `gzip`). + pub kind: String, + /// Whether the decode was lossy (an unpaired surrogate, a truncated payload, + /// bytes substituted with U+FFFD). Structural, so a consumer cannot present a + /// lossy decode as faithful. + pub lossy: bool, + /// Heuristic confidence in `(0.0, 1.0]` that this reading is correct. + pub confidence: f32, +} + +/// A consumer-supplied interpreter of opaque `BLOB` values. Implemented OUTSIDE +/// this crate (a `blob-decoder` adapter, or the built-in Local Storage decoder) so +/// the reader library carries no decode dependency. +pub trait BlobInterpreter { + /// Interpret a `BLOB`'s bytes in the given schema context, or `None` when this + /// interpreter recognises nothing in them. + fn interpret(&self, bytes: &[u8], ctx: &BlobContext<'_>) -> Option; +} + +/// Confidence assigned when a BLOB is decoded under a matching schema-name prior +/// (a `WebKit` Local Storage `ItemTable` column): the context makes UTF-16-LE the +/// confident reading, not a coincidental structural match. +const SCHEMA_MATCHED_CONFIDENCE: f32 = 0.95; + +/// The built-in, dependency-free [`BlobInterpreter`] for `WebKit`/Chromium Local +/// Storage. A `.localstorage` `ItemTable.value` BLOB holds its string as raw +/// UTF-16-LE (no BOM, no type byte); this decodes it — but ONLY when the schema +/// context identifies the `ItemTable` column, which is the prior that makes the +/// UTF-16-LE reading confident. An arbitrary BLOB is not this interpreter's job +/// (that is the general `blob-decoder` adapter); it returns `None`. +#[derive(Debug, Clone, Copy, Default)] +pub struct LocalStorageInterpreter; + +impl BlobInterpreter for LocalStorageInterpreter { + fn interpret(&self, bytes: &[u8], ctx: &BlobContext<'_>) -> Option { + // Fire only under the ItemTable schema-name prior — without it there is no + // evidence these bytes are UTF-16-LE rather than any other encoding. + if !ctx.table.is_some_and(is_local_storage_item_table) { + return None; + } + let decoded = decode_localstorage_value(bytes); + Some(Interpretation { + text: decoded.text, + kind: "utf-16le".to_string(), + lossy: decoded.lossy, + confidence: SCHEMA_MATCHED_CONFIDENCE, + }) + } +} + +/// Apply `interpreter` to every `BLOB` in `values`, returning `(column_index, +/// interpretation)` for each blob the interpreter recognised. Non-blob values are +/// skipped. `interpreter == None` yields an empty result — the default, unchanged +/// behaviour, so this passthrough is non-breaking for every existing caller. +#[must_use] +pub fn interpret_values( + values: &[Value], + ctx: &BlobContext<'_>, + interpreter: Option<&dyn BlobInterpreter>, +) -> Vec<(usize, Interpretation)> { + let Some(interpreter) = interpreter else { + return Vec::new(); + }; + values + .iter() + .enumerate() + .filter_map(|(i, value)| match value { + Value::Blob(bytes) => interpreter.interpret(bytes, ctx).map(|interp| (i, interp)), + _ => None, + }) + .collect() +} diff --git a/forensic/src/lib.rs b/forensic/src/lib.rs index f2e1d2e..d34a523 100644 --- a/forensic/src/lib.rs +++ b/forensic/src/lib.rs @@ -30,6 +30,7 @@ pub mod blob; pub mod case_uco; +pub mod interpret; use forensicnomicon::report::{ Confidence, Evidence, Finding, Location, Observation, Severity, Source, diff --git a/forensic/tests/blob_interpreter.rs b/forensic/tests/blob_interpreter.rs new file mode 100644 index 0000000..bebba55 --- /dev/null +++ b/forensic/tests/blob_interpreter.rs @@ -0,0 +1,80 @@ +//! Step 1 of the blob-interpreter seam (dependency inversion): sqlite-forensic +//! defines the *contract* for context-aware BLOB decoding — a `BlobInterpreter` +//! trait plus `BlobContext`/`Interpretation` types — and a passthrough that +//! applies an `Option<&dyn BlobInterpreter>` to a record's values. The library +//! gains ZERO decoding dependencies and no MSRV cost; a consumer (blob-decoder +//! adapter, or the built-in localstorage decoder) supplies the interpreter. +//! +//! This step proves the seam: an interpreter's `Interpretation` reaches the output +//! when supplied, and `None` leaves the output empty (unchanged behaviour). + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use sqlite_core::Value; +use sqlite_forensic::interpret::{interpret_values, BlobContext, BlobInterpreter, Interpretation}; + +/// A trivial test interpreter: any BLOB is "interpreted" as its byte length. Real +/// interpreters (localstorage UTF-16, blob-decoder) live in consumers. +struct ByteLen; + +impl BlobInterpreter for ByteLen { + fn interpret(&self, bytes: &[u8], _ctx: &BlobContext<'_>) -> Option { + Some(Interpretation { + text: format!("{} bytes", bytes.len()), + kind: "bytelen".to_string(), + lossy: false, + confidence: 1.0, + }) + } +} + +fn sample_values() -> Vec { + vec![ + Value::Integer(7), + Value::Text("hello".to_string()), + Value::Blob(vec![0xDE, 0xAD, 0xBE, 0xEF]), + ] +} + +#[test] +fn interpreter_reaches_only_the_blob_value() { + let values = sample_values(); + let ctx = BlobContext { + table: Some("moz_places"), + column: None, + }; + let out = interpret_values(&values, &ctx, Some(&ByteLen)); + + // Exactly the one BLOB (index 2) is interpreted; the int/text are skipped. + assert_eq!(out.len(), 1, "only the BLOB value is interpreted: {out:?}"); + let (idx, interp) = &out[0]; + assert_eq!(*idx, 2, "the BLOB is at column index 2"); + assert_eq!(interp.text, "4 bytes"); + assert_eq!(interp.kind, "bytelen"); + assert!(!interp.lossy); +} + +#[test] +fn none_interpreter_is_passthrough_no_interpretations() { + let values = sample_values(); + let ctx = BlobContext { + table: None, + column: None, + }; + let out = interpret_values(&values, &ctx, None); + assert!( + out.is_empty(), + "with no interpreter, behaviour is unchanged (no interpretations): {out:?}" + ); +} + +#[test] +fn no_blobs_yields_no_interpretations_even_with_an_interpreter() { + let values = vec![Value::Integer(1), Value::Text("x".to_string()), Value::Null]; + let ctx = BlobContext { + table: None, + column: None, + }; + let out = interpret_values(&values, &ctx, Some(&ByteLen)); + assert!(out.is_empty(), "no BLOB → nothing to interpret: {out:?}"); +} diff --git a/forensic/tests/localstorage_interpreter.rs b/forensic/tests/localstorage_interpreter.rs new file mode 100644 index 0000000..01e57f8 --- /dev/null +++ b/forensic/tests/localstorage_interpreter.rs @@ -0,0 +1,76 @@ +//! Step 2 of the blob-interpreter seam: the built-in [`LocalStorageInterpreter`] +//! (no external deps) that decodes a `WebKit` Local Storage `ItemTable.value` BLOB — +//! raw UTF-16-LE — using the schema-name context as the high-confidence prior. +//! This is "schema context lifts a structural-Low reading to High" made concrete: +//! an arbitrary blob is not our job (that's the blob-decoder adapter), but a blob +//! from a known `ItemTable` column IS confidently UTF-16-LE. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use sqlite_forensic::interpret::{BlobContext, BlobInterpreter, LocalStorageInterpreter}; + +/// UTF-16-LE bytes for an ASCII string (each char → 2 little-endian bytes). +fn utf16le(s: &str) -> Vec { + s.encode_utf16().flat_map(u16::to_le_bytes).collect() +} + +#[test] +fn decodes_item_table_blob_with_high_confidence() { + let interp = LocalStorageInterpreter; + let ctx = BlobContext { + table: Some("ItemTable"), + column: Some("value"), + }; + let out = interp + .interpret(&utf16le("https://example.test/path"), &ctx) + .expect("an ItemTable BLOB must be interpreted"); + assert_eq!(out.text, "https://example.test/path"); + assert!( + out.kind.contains("utf-16"), + "kind names the encoding: {out:?}" + ); + assert!(!out.lossy, "clean UTF-16-LE is not lossy"); + assert!( + out.confidence >= 0.9, + "the ItemTable schema-name match is a high-confidence prior: {out:?}" + ); +} + +#[test] +fn does_not_fire_without_the_item_table_context() { + // Without the ItemTable schema context there is no prior that the bytes are + // UTF-16-LE, so this interpreter recognises nothing (blob-decoder's job). + let interp = LocalStorageInterpreter; + let ctx = BlobContext { + table: Some("moz_places"), + column: Some("value"), + }; + assert!( + interp.interpret(&utf16le("anything"), &ctx).is_none(), + "no ItemTable context → the localstorage interpreter must not fire" + ); + // And with no table context at all. + let bare = BlobContext { + table: None, + column: None, + }; + assert!(interp.interpret(&utf16le("x"), &bare).is_none()); +} + +#[test] +fn flags_a_lossy_decode() { + // An odd-length blob cannot be whole UTF-16-LE code units; the decode is lossy + // and the interpretation must say so (secure by design — never faithful). + let interp = LocalStorageInterpreter; + let ctx = BlobContext { + table: Some("ItemTable"), + column: None, + }; + let mut bytes = utf16le("ok"); + bytes.push(0x41); // trailing odd byte + let out = interp.interpret(&bytes, &ctx).expect("still interpreted"); + assert!( + out.lossy, + "an odd-length UTF-16-LE blob is a lossy decode: {out:?}" + ); +}