fix(whatsapp): resolve msgstore.db columns by name, and refuse a schema the data contradicts - #3
Draft
h4x0r wants to merge 11 commits into
Draft
fix(whatsapp): resolve msgstore.db columns by name, and refuse a schema the data contradicts#3h4x0r wants to merge 11 commits into
h4x0r wants to merge 11 commits into
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A real 2023-era Android
msgstore.dbproduced a structurally complete butentirely 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.sqlwas authored to thesame 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 TABLEout ofsqlite_masterand builds a name to position map: commas inside
NUMERIC(10, 2)andDEFAULT 'x,y'do not split a column, table-level constraints occupy noposition, quoted identifiers are unwrapped, malformed DDL yields an empty map
rather than a panic. A column the schema does not declare resolves to
None, sono 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 mergedon
duration— that alone was collapsing unrelated calls of equal length intosingle group-call records.
A wrong map is now an error, not a report (
src/schema_gate.rs). Beforeinterpreting 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 doesnot render, and rejecting good evidence over it would be the worse failure.
Refusing beats reporting empty. A database with no modern
messagetableaborts and names the legacy
messagestable if it finds one, or lists thetables that are there. Zero messages must not look like a clean device.
A distribution-level detector.
detect_timestamp_anomaliescomparesneighbouring messages, and 245,981 messages all dated 1970 are in perfect order,
so it had nothing to report.
TimestampDistributionAnomalylooks at the set andfires above a 5% share of pre-2009 timestamps. Its standing job is genuine
tampering, which has the same signature.
detect_schema_versionclassifies on table presence.user_versionisapp-defined and reads
1on the real device, so the>= 100arm was noise. Theplugin now passes the real table list instead of an empty slice.
Fixtures that can falsify a positional reader
real_modern_schema.sqlshuffled_schema.sql_iddeclared mid-tablelegacy_schema.sqlmodern_schema.sqlThe 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 KEYas a NULL at its declared position, sovalues[i]isthe column at DDL position
ieven when_idis not declared first.Validation
tests/real_msgstore_validation.rsreconciles againstsqlite3reading thesame evidence: counts, the per-year histogram bucket for bucket, no message
dated 1970, senders carrying an
@, hourly spread, and no anomaly warningraised 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_MSGSTOREandCHAT4N6_REAL_INPUT_DIR, which are not set in this environment; the harness wasexercised against a synthetic stand-in to prove the oracle plumbing works.
Everything else runs in CI.
docs/validation.mdrecords the method and tierseach check by who confirms it.
Deliberately not done
Per the handoff's deferred list:
message_mediasupport (so media metadata isabsent, 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 indocs/validation.md.Note on CI
cargo test --workspaceis green: 1183 passed.cargo clippy --workspace -D warningsandcargo fmt --all --checkwere already failing onmainbeforethis branch — 19 clippy findings across
chat4n6-reportandchat4n6-sqlite-forensics, plus rustfmt drift incli/andchat4n6-fs/.Measured for the
chat4n6-whatsapppackage 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