From 90b99cf98544accbf77af336aba56369be4bbfca Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 00:12:29 +0800 Subject: [PATCH 1/4] =?UTF-8?q?test(forensic):=20RED=20=E2=80=94=20BlobInt?= =?UTF-8?q?erpreter=20seam,=20step=201=20(trait=20+=20None-passthrough)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dependency-inversion seam so library consumers can opt into context-aware BLOB decoding without the reader library gaining any decode dependency or MSRV cost. Step 1 defines the contract and proves it: - trait BlobInterpreter { interpret(&self, bytes, ctx) -> Option } - BlobContext { table, column } — the schema context the library owns - Interpretation { text, kind, lossy, confidence } — lossy is a struct field (secure by design: a consumer can't render a lossy decode as faithful) - interpret_values(&[Value], &BlobContext, Option<&dyn BlobInterpreter>) -> Vec<(usize, Interpretation)>: applies the interpreter to each BLOB value; None → empty (unchanged behaviour). Scoped to &[Value] (not CarvedRecord, whose non_exhaustive RecoverySource blocks external construction). Fails: the interpret module does not exist yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- forensic/tests/blob_interpreter.rs | 80 ++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 forensic/tests/blob_interpreter.rs 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:?}"); +} From ffcf1c4af5ff6f6fcf5d10c654e07c1ae12a4878 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 00:17:05 +0800 Subject: [PATCH 2/4] =?UTF-8?q?feat(forensic):=20GREEN=20=E2=80=94=20BlobI?= =?UTF-8?q?nterpreter=20seam,=20step=201=20(trait=20+=20None-passthrough)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New forensic::interpret module (zero decode deps, MSRV 1.80 untouched): - trait BlobInterpreter { interpret(&self, bytes, &BlobContext) -> Option } - BlobContext { table, column } — schema context as a decoding prior - Interpretation { text, kind, lossy, confidence } — lossy is a struct field (secure by design; a consumer can't render a lossy decode as faithful) - interpret_values(&[Value], &BlobContext, Option<&dyn BlobInterpreter>) -> Vec<(usize, Interpretation)>: applies the interpreter per BLOB; None → empty (unchanged, non-breaking). Dependency inversion: the library owns the contract + context; a consumer supplies the decoder (blob-decoder adapter, or the built-in localstorage decoder in later steps). Workspace clippy/fmt/rustdoc clean; function coverage 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- forensic/src/interpret.rs | 77 +++++++++++++++++++++++++++++++++++++++ forensic/src/lib.rs | 1 + 2 files changed, 78 insertions(+) create mode 100644 forensic/src/interpret.rs diff --git a/forensic/src/interpret.rs b/forensic/src/interpret.rs new file mode 100644 index 0000000..b95cff8 --- /dev/null +++ b/forensic/src/interpret.rs @@ -0,0 +1,77 @@ +//! 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::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; +} + +/// 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, From e5c1c9aed070ecadb1fbd09593908799d3dc04e5 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 00:22:15 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test(forensic):=20RED=20=E2=80=94=20built-i?= =?UTF-8?q?n=20LocalStorageInterpreter,=20seam=20step=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The built-in (dependency-free) BlobInterpreter for WebKit Local Storage: an ItemTable.value BLOB is raw UTF-16-LE, so with the ItemTable schema-name context it decodes with high confidence (kind "utf-16", lossy flagged on an odd-length blob). Without that context it recognises nothing (an arbitrary blob is the blob-decoder adapter's job). Fails: LocalStorageInterpreter does not exist yet. Co-Authored-By: Claude Opus 4.8 (1M context) --- forensic/tests/localstorage_interpreter.rs | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 forensic/tests/localstorage_interpreter.rs diff --git a/forensic/tests/localstorage_interpreter.rs b/forensic/tests/localstorage_interpreter.rs new file mode 100644 index 0000000..39b57be --- /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:?}" + ); +} From 52c6a01a09c58d9310a1c1d624b665fe15623031 Mon Sep 17 00:00:00 2001 From: Albert Hui Date: Sun, 12 Jul 2026 00:25:14 +0800 Subject: [PATCH 4/4] =?UTF-8?q?feat(forensic):=20GREEN=20=E2=80=94=20built?= =?UTF-8?q?-in=20LocalStorageInterpreter,=20seam=20step=202?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalStorageInterpreter implements BlobInterpreter over sqlite-core's decode_localstorage_value + is_local_storage_item_table (no new deps). It fires ONLY under the ItemTable schema-name prior — that context is what makes UTF-16-LE the confident reading (0.95); an arbitrary blob returns None (the general blob-decoder adapter's job). Carries the lossy flag through verbatim, so an odd-length blob is never presented as a faithful decode. The "schema context lifts a structural-Low reading to High" seam, concrete. Workspace clippy/fmt/rustdoc clean; function coverage 100%. Co-Authored-By: Claude Opus 4.8 (1M context) --- forensic/src/interpret.rs | 33 +++++++++++++++++++++- forensic/tests/localstorage_interpreter.rs | 2 +- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/forensic/src/interpret.rs b/forensic/src/interpret.rs index b95cff8..5088bd2 100644 --- a/forensic/src/interpret.rs +++ b/forensic/src/interpret.rs @@ -14,7 +14,7 @@ //! cannot render a lossy decode as a faithful one — the uncertainty is structural, //! not a side-channel warning. -use sqlite_core::Value; +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 @@ -53,6 +53,37 @@ pub trait BlobInterpreter { 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 diff --git a/forensic/tests/localstorage_interpreter.rs b/forensic/tests/localstorage_interpreter.rs index 39b57be..01e57f8 100644 --- a/forensic/tests/localstorage_interpreter.rs +++ b/forensic/tests/localstorage_interpreter.rs @@ -1,5 +1,5 @@ //! Step 2 of the blob-interpreter seam: the built-in [`LocalStorageInterpreter`] -//! (no external deps) that decodes a WebKit Local Storage `ItemTable.value` BLOB — +//! (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