Skip to content

feat(core): decode WebKit/Chrome .localstorage UTF-16-LE values#2

Merged
h4x0r merged 4 commits into
mainfrom
feat/localstorage-utf16-decode
Jul 10, 2026
Merged

feat(core): decode WebKit/Chrome .localstorage UTF-16-LE values#2
h4x0r merged 4 commits into
mainfrom
feat/localstorage-utf16-decode

Conversation

@h4x0r

@h4x0r h4x0r commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What

A small UTF-16-LE value-decode convenience for WebKit/Chrome .localstorage files. This is not a new reader — a .localstorage file is a standard SQLite database that sqlite-forensic already opens read-only, dumps (ItemTable), and carves. The one artifact-specific gap: ItemTable.value is a BLOB holding the string as raw UTF-16-LE (little-endian, no BOM, no type-prefix byte), so today it surfaces as opaque hex instead of readable text.

API (in core)

  • decode_localstorage_value(blob: &[u8]) -> LocalStorageValue { text: String, lossy: bool } — the lossy flag is a struct field, not a side-channel warning, so a caller cannot render a lossy decode as if it were faithful (secure by design). An odd-length BLOB (a trailing half code unit) or an unpaired surrogate yields U+FFFD and sets lossy; an empty BLOB decodes to the empty string; it never panics.
  • is_local_storage_item_table(table_name: &str) -> bool — recognizes the distinctive ItemTable name so callers know when the decode applies. Name-keyed on purpose: the real WebKit/Chromium schema declares the columns with ON CONFLICT clauses (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB NOT NULL ON CONFLICT FAIL) that a lightweight CREATE TABLE parse does not split cleanly.

DRY

The UTF-16 code-unit pairing is factored into one private decode_utf16_units (which now reports lossiness), reused by the existing TextEncoding::decode_utf16 (behavior-preserving — the DB-encoding path still discards the flag) and by the new Local Storage decode. No second UTF-16 decoder was added.

Validation (oracle-checked, not just self-encoded)

  • Independent sqlite3-CLI oracle (gated, skips if absent): a real .localstorage file is built with genuine UTF-16-LE BLOB values — including CJK and emoji (surrogate pairs) — read back through the normal reader, decoded, and asserted to recover the exact original strings.
  • Hand-specified UTF-16-LE bytes derived from the Unicode code points + the surrogate-pair formula (not Rust's encoder), so the decoder is checked against the documented construction.
  • Adversarial: empty, odd-length, lone-surrogate, and NULL inputs — graceful (lossy / replacement / distinct Value::Null), never a panic.

Gate

cargo fmt --all -- --check clean · cargo clippy --workspace --all-targets -- -D warnings clean · cargo test --workspace 514 passed / 0 failed.

Two commits: RED (failing tests) then GREEN (implementation).

🤖 Generated with Claude Code

h4x0r and others added 4 commits July 10, 2026 23:36
…hema

Failing tests for a WebKit/Chrome Local Storage value-decode convenience:
decode_localstorage_value(&[u8]) -> LocalStorageValue { text, lossy } and an
is_local_storage_item_table schema recognizer. Neither exists yet, so the tests
fail to compile (RED).

Coverage: an independent oracle of hand-specified UTF-16-LE bytes (derived from
the Unicode code points + surrogate formula, not Rust's encoder); adversarial
empty / odd-length / lone-surrogate inputs (must set lossy, never panic); and a
sqlite3-CLI real .localstorage round-trip incl. CJK + emoji, gated/skip-if-absent.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A .localstorage file is a standard SQLite database sqlite-forensic already
opens, dumps, and carves; the one artifact-specific gap is that ItemTable.value
is a BLOB holding the string as raw UTF-16-LE (no BOM, no type-prefix byte), so
it surfaces as opaque hex. This adds a thin decode convenience — not a new
reader:

- decode_localstorage_value(&[u8]) -> LocalStorageValue { text, lossy }. The
  lossy flag is a struct field (secure by design) so a caller cannot render a
  lossy decode as faithful. Odd-length BLOB or unpaired surrogate => U+FFFD +
  lossy; empty => empty string; never panics.
- is_local_storage_item_table(&str) recognizes the distinctive ItemTable name so
  callers know when the decode applies (name-keyed: the real schema's ON CONFLICT
  clauses defeat a lightweight column-name parse).
- DRY: the UTF-16 pairing is factored into decode_utf16_units, reused by the
  existing TextEncoding::decode_utf16 (behavior-preserving) — no second decoder.

Validated against an independent sqlite3-CLI oracle: a real .localstorage with
UTF-16-LE BLOB values (incl. CJK + emoji surrogate pairs) round-trips exactly
through the normal reader; plus hand-specified UTF-16-LE bytes (from the Unicode
code points, not Rust's encoder) and adversarial empty/odd/lone-surrogate/NULL
inputs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RUSTSEC-2026-0194 / RUSTSEC-2026-0195 are DoS-only (not RCE) and reach the tree
only via calamine v0.35.0, a dev-dependency of the CLI that pins quick-xml ^0.39,
so the tree cannot bump to >=0.41 until calamine widens its requirement. Fleet-
wide-fresh: main was green before these advisories published. Interim ignore;
drop once calamine allows quick-xml >=0.41.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@h4x0r h4x0r marked this pull request as ready for review July 10, 2026 17:22
@h4x0r h4x0r merged commit 7d4e2b4 into main Jul 10, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant