Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
108 changes: 108 additions & 0 deletions forensic/src/interpret.rs
Original file line number Diff line number Diff line change
@@ -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<Interpretation>;
}

/// 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<Interpretation> {
// 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()
}
1 change: 1 addition & 0 deletions forensic/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

pub mod blob;
pub mod case_uco;
pub mod interpret;

use forensicnomicon::report::{
Confidence, Evidence, Finding, Location, Observation, Severity, Source,
Expand Down
80 changes: 80 additions & 0 deletions forensic/tests/blob_interpreter.rs
Original file line number Diff line number Diff line change
@@ -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<Interpretation> {
Some(Interpretation {
text: format!("{} bytes", bytes.len()),
kind: "bytelen".to_string(),
lossy: false,
confidence: 1.0,
})
}
}

fn sample_values() -> Vec<Value> {
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:?}");
}
76 changes: 76 additions & 0 deletions forensic/tests/localstorage_interpreter.rs
Original file line number Diff line number Diff line change
@@ -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<u8> {
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:?}"
);
}
Loading