Skip to content

fix(whatsapp): resolve msgstore.db columns by name, and refuse a schema the data contradicts - #3

Draft
h4x0r wants to merge 11 commits into
mainfrom
worktree-whatsapp-schema-resolver
Draft

fix(whatsapp): resolve msgstore.db columns by name, and refuse a schema the data contradicts#3
h4x0r wants to merge 11 commits into
mainfrom
worktree-whatsapp-schema-resolver

Conversation

@h4x0r

@h4x0r h4x0r commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Summary

A real 2023-era Android msgstore.db produced a structurally complete but
entirely wrong report: every message dated 1970-01-01, senders misattributed,
message text wrong, 62 calls instead of 166, and no forensic warning raised.
The pipeline was fine; the WhatsApp plugin read records by hardcoded column
ordinals
that matched only our own fixtures.

The fixtures could never have caught it. modern_schema.sql was authored to the
same layout the ordinals assumed, so code and fixture agreed while both were
wrong about real data.

What changed

Columns are resolved by name from the database's own DDL (src/columns.rs).
A depth- and quote-aware parser reads each CREATE TABLE out of sqlite_master
and builds a name to position map: commas inside NUMERIC(10, 2) and
DEFAULT 'x,y' do not split a column, table-level constraints occupy no
position, quoted identifiers are unwrapped, malformed DDL yields an empty map
rather than a panic. A column the schema does not declare resolves to None, so
no field is ever read off a neighbouring column.

This applies to every table the extractor reads, not just the four in the
diagnosis. The modern schema has no call_row_id, so calls are no longer merged
on duration — that alone was collapsing unrelated calls of equal length into
single group-call records.

A wrong map is now an error, not a report (src/schema_gate.rs). Before
interpreting anything, up to 512 rows are sampled and 90% must carry a plausible
epoch-millisecond timestamp. The error names the table, column, resolved
ordinal, observed pass rate, expected bounds, and up to three offending values
with their _id. Not 100%: the real database has one message whose year does
not render, and rejecting good evidence over it would be the worse failure.

Refusing beats reporting empty. A database with no modern message table
aborts and names the legacy messages table if it finds one, or lists the
tables that are there. Zero messages must not look like a clean device.

A distribution-level detector. detect_timestamp_anomalies compares
neighbouring messages, and 245,981 messages all dated 1970 are in perfect order,
so it had nothing to report. TimestampDistributionAnomaly looks at the set and
fires above a 5% share of pre-2009 timestamps. Its standing job is genuine
tampering, which has the same signature.

detect_schema_version classifies on table presence. user_version is
app-defined and reads 1 on the real device, so the >= 100 arm was noise. The
plugin now passes the real table list instead of an empty slice.

Fixtures that can falsify a positional reader

Fixture Provenance
real_modern_schema.sql Real device DDL, synthetic rows. Confirmed positions read off the case device; intervening columns from published documentation, position-preserving and never read
shuffled_schema.sql Same names and data, every column order permuted, _id declared mid-table
legacy_schema.sql Pre-2018 generation, from published documentation — not the case device, and labelled so
modern_schema.sql Kept, but now states it is simplified and why

The real and shuffled pair makes position-independence a property under test:
two databases differing only in declaration order must extract identically. A
characterisation test pins the invariant underneath — SQLite writes an
INTEGER PRIMARY KEY as a NULL at its declared position, so values[i] is
the column at DDL position i even when _id is not declared first.

Validation

tests/real_msgstore_validation.rs reconciles against sqlite3 reading the
same evidence: counts, the per-year histogram bucket for bucket, no message
dated 1970, senders carrying an @, hourly spread, and no anomaly warning
raised against the extraction's own dates. Expected values are derived from the
oracle at run time, not transcribed. A count mismatch fails with a breakdown by
evidence source and a distinct-rowid tally.

This Tier-1 run has not been executed. It needs CHAT4N6_REAL_MSGSTORE and
CHAT4N6_REAL_INPUT_DIR, which are not set in this environment; the harness was
exercised against a synthetic stand-in to prove the oracle plumbing works.
Everything else runs in CI. docs/validation.md records the method and tiers
each check by who confirms it.

Deliberately not done

Per the handoff's deferred list: message_media support (so media metadata is
absent, not wrong, on a modern schema), and auditing the iOS/Signal/Telegram
plugins for the same pattern. Auxiliary-table column names remain unverified
against a device — where a real schema spells one differently, the field
resolves to None. All recorded in docs/validation.md.

Note on CI

cargo test --workspace is green: 1183 passed. cargo clippy --workspace -D warnings and cargo fmt --all --check were already failing on main before
this branch — 19 clippy findings across chat4n6-report and
chat4n6-sqlite-forensics, plus rustfmt drift in cli/ and chat4n6-fs/.
Measured for the chat4n6-whatsapp package tree: 32 clippy findings on main,
15 here, none introduced.
Clearing the rest is a separate sweep rather than
churn folded into this fix.

Tests were written first throughout: four RED commits (24, 8, 4 and 1 failing
tests) each followed by the GREEN commit that satisfies them.

🤖 Generated with Claude Code

h4x0r and others added 11 commits July 30, 2026 12:11
…ests fail)

The WhatsApp extractor reads msgstore.db records by hardcoded ordinals that
only match our own simplified fixtures. Against a real 2023-era Android
msgstore.db every ordinal lands on the wrong column: values[4] is
sender_jid_row_id rather than timestamp (every message dated 1970-01-01),
jid[1] is the bare `user` rather than raw_string, chat[2] is `hidden` rather
than subject, and call_log[7] is `duration` rather than call_row_id (so
unrelated calls sharing a duration merge into one record).

The fixtures could never catch this: modern_schema.sql was authored to the
same layout the ordinals assume, so code and fixture agree while both are
wrong about real data.

Adds:
- tests/fixtures/real_modern_schema.sql — the CREATE TABLE shapes read out of
  a real device (DDL only; no case identifiers, contents or paths), with
  synthetic rows whose timestamps are a known 2022 value.
- src/columns.rs — API and unit tests for DDL-driven name -> ordinal
  resolution, with a null implementation so the tests fail on assertions.
- extractor::real_schema_tests — extraction assertions over the real DDL, plus
  a characterisation test proving the invariant the resolver rests on: SQLite
  writes the INTEGER PRIMARY KEY as a NULL at its *declared* position, so
  values[i] is the column at DDL position i even when _id is not declared
  first. That one passes today.
- schema: user_version = 1 on the real device, so the `>= 100` arm classifies
  a legacy-table database as Modern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces every hardcoded column ordinal in the WhatsApp extractor with a
position resolved from the database's own CREATE TABLE SQL.

- columns.rs: depth- and quote-aware DDL parser building a name -> position
  map per table. Commas inside NUMERIC(10, 2) and DEFAULT 'x,y' do not split a
  column; PRIMARY KEY/UNIQUE/CHECK/FOREIGN/CONSTRAINT table constraints occupy
  no position; quoted identifiers are unwrapped. Malformed DDL yields an empty
  map, never a panic.
- A column the schema does not declare resolves to None and the field is
  omitted, so no field is ever read off a neighbouring column. The modern
  schema has no call_row_id, so calls are no longer merged on `duration` —
  that alone was collapsing unrelated calls into single group-call records.
- Every table the extractor reads goes through the resolver: message, jid,
  chat, call_log, message_quoted, message_add_on, message_edit_info,
  receipt_user, message_forwarded, group_participant_user, and wa_contacts.
- Bootstrap gate: a database whose sqlite_master yields no CREATE TABLE at all
  is an error naming the input size, not an empty result set that reads like a
  clean database.
- detect_schema_version now classifies on table presence only. user_version is
  app-defined and reads 1 on a real 2023-era device, so the `>= 100` arm was
  noise; the plugin now passes the real table list instead of an empty slice.
- Drops schema::cols and the bespoke key_id DDL scanner it fed.

314 tests pass (286 before + 28 new).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fail)

Resolving a column by name is only as good as the DDL it came from. If the map
is wrong the extractor still emits a structurally complete report — the failure
class that cannot be told apart from a correct run. The map must therefore be
checked against the data before any of it is interpreted.

schema_gate::validate_message_columns is specified to reject:
- a message table with no resolvable timestamp or chat_row_id, naming the column
- timestamps that are not epoch milliseconds (pre-2009, or past acquisition
  time plus a day of clock skew), quoting offending values and the resolved
  ordinal
- a column position no record is long enough to carry
- non-integer values sitting at the resolved position, shown verbatim

and to accept an empty message table and a handful of implausible rows among
plausible ones — the real database carries exactly one message whose year does
not render, and rejecting good evidence over it would be the worse failure.

Ships as a null implementation returning Ok so the tests fail on assertions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
extract_from_msgstore now samples up to 512 live message rows and requires 90%
of them to carry a plausible epoch-millisecond timestamp (2009-01-01 through
acquisition time plus a day of clock skew) before interpreting any record. A
map the data contradicts aborts extraction instead of producing a report.

The error names the table, the column, the ordinal it resolved to, the observed
pass rate, the expected bounds, and up to three offending values with their
_id — everything needed to identify the schema by hand. Blob values state their
full length and label the 16 bytes shown, so nothing is silently elided.

An empty message table stays a valid result: degrade-to-empty is only
legitimate once the bootstrap is known good.

328 crate tests, 1163 workspace tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No forensic warning fired on 245,981 messages all dated 1970-01-01 — the most
anomalous timestamp distribution a msgstore can have. detect_timestamp_anomalies
compares each message with its neighbour, and a set that is uniformly wrong is
in perfect order, so it had nothing to report. The gap is the level of analysis,
not the threshold.

Specifies detect_timestamp_distribution_anomaly over the whole extraction:
fires when more than 5% of messages predate WhatsApp's 2009 release, reporting
the count, the share, and the modal implausible instant with its occurrence
count. The share is measured across every chat, so ten bad messages in their own
chat are 10% of the extraction rather than 100% of one thread.

One test asserts the pairwise detector stays silent on the same input, pinning
why the set-level check has to exist rather than duplicating an existing one.

Adds ForensicWarning::TimestampDistributionAnomaly and its Display arm; the
detector ships as a null implementation returning no warnings.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
detect_timestamp_distribution_anomaly counts messages predating 2009-01-01
across the whole extraction and emits TimestampDistributionAnomaly above a 5%
share, carrying the total, the count, the percentage, and the modal implausible
instant with its occurrence count. Ties on the modal instant break toward the
earlier one so repeated runs report the same value.

Shares the 2009 bound with the schema gate rather than restating it, and
compares in milliseconds so no fallible conversion sits on the hot path.

Wired into extract_from_msgstore alongside the existing detectors. Its standing
job is genuine tampering: a database whose timestamps were rewritten has the
same signature as one read off the wrong column.

335 crate tests, 1170 workspace tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
modern_schema.sql was authored to the same layout the extractor's ordinals
assumed, so it agreed with the code while both were wrong about real data. Two
fixtures that cannot do that:

- shuffled_schema.sql — real_modern_schema.sql with every column order
  permuted and _id declared mid-table. Paired with the real fixture it makes
  position-independence a property under test: two databases differing only in
  declaration order must extract identically. This passes now, which is the
  point — it is the regression guard that fails the moment an ordinal returns.
- legacy_schema.sql — the pre-2018 `messages` + `chat_list` generation, taken
  from published schema documentation rather than the case device (nothing in
  the case is of this generation), and labelled as such.

The legacy fixture exposes a real gap, and that is the failing test: a legacy
database has no `message` table, so extraction reports zero messages — which is
indistinguishable from a clean device. It must name the generation it found and
refuse.

modern_schema.sql keeps its simplified shapes but now says so, and says why it
is kept: it is the only fixture exercising media metadata on the `message`
table, and its aux-table column names are unverified against any device.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A database with no modern `message` table now aborts extraction rather than
producing a report of zero messages, which reads exactly like a clean device.

- A legacy `messages` table present and no `message` table: the error names the
  generation found and states that this extractor reads the modern one only.
- Neither table: the error lists the tables that are there, with the full count
  and an explicit note when names beyond the first 20 are omitted.

339 crate tests, 1174 workspace tests pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…dation.md

Adds an env-gated integration test that reconciles the extractor against
sqlite3 reading the same evidence file — an independent implementation, not a
fixture we authored.

Expected values are derived from the oracle at run time rather than transcribed
as constants, so the test reconciles against whatever database it is pointed
at: message/chat/call counts, the per-year timestamp histogram bucket for
bucket, no message dated 1970, senders carrying an '@', hourly spread across at
least 12 buckets, and no TimestampDistributionAnomaly raised against the
extraction's own dates. A count mismatch fails with a breakdown by evidence
source and a distinct-rowid tally, so an over-count is diagnosed rather than
merely reported.

Without CHAT4N6_REAL_MSGSTORE / CHAT4N6_REAL_INPUT_DIR every test skips and
says why; without sqlite3 on PATH the reconciling ones skip. A variable that is
set but points at no readable file fails loudly — a typo must not look like
absent evidence. The hourly-spread check needs 1000 messages before it means
anything and skips below that. Nothing prints or asserts on message content,
JIDs, subjects, numbers or paths.

Verified by pointing the harness at a synthetic stand-in built from
real_modern_schema.sql: the oracle plumbing, the year histogram comparison and
the skip paths all exercise correctly.

docs/validation.md records the method, tiers each check by who confirms it, and
lists the gaps that remain open: message_media metadata, unverified aux-table
column names, live-layer-only interpretation, the pairwise timestamp detector
being unreachable after sorting, and the three unaudited sibling plugins.

The Tier-1 run itself needs the case evidence and has not been executed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`cargo fmt -p chat4n6-whatsapp` during development reflowed twelve modules
this work never edits — album, cdn, contact_report, group_metadata, link,
location, orphaned_media, platform, poll, status, system_event and the
plugin-api lib root — adding roughly 700 lines of churn that buried the actual
change. Restored to their state on main; the ForensicWarning variant in
types.rs is re-applied in the file's existing style.

The workspace is not rustfmt-clean on main (cli/, chat4n6-fs/ and others drift
too). Clearing that belongs in its own sweep, not in a schema-resolution fix.

Verified: 1183 workspace tests pass, and the clippy finding set for the
chat4n6-whatsapp package tree goes from 32 on main to 15 here, with none
introduced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The handoff deferred three items out of the column-resolution fix and asked
that they be routed somewhere durable rather than left in a working doc. Filed
as stories in the repo's existing format, all passes:false:

- whatsapp/message-media-metadata — read modern-schema media metadata from
  message_media, which replaced the media columns on the message table, plus
  confirming the auxiliary table column names against real device DDL. Both are
  named gaps in docs/validation.md today.
- platforms/schema-resolution-audit — chat4n6-ios-whatsapp, chat4n6-signal and
  chat4n6-telegram still read records at fixed values[] indices and have not
  been checked against real DDL. Notes that the iOS timestamp base is seconds
  since 2001-01-01, so the gate's plausibility bounds need converting rather
  than copying.
- sqlite-engine/raw-image-unallocated-input — accept a raw decrypted partition
  image so unallocated space can be carved at all (PlaintextDirFs reports no
  unallocated regions by design), and map the recovery layers beyond the live
  btree into messages through the same resolved column maps.

Each story names the docs/validation.md gap it closes, so the doc and the
backlog cannot drift apart. Steps describe the evidence class generically; no
case identifiers, paths or content.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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