diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml new file mode 100644 index 00000000..64f18e71 --- /dev/null +++ b/.github/workflows/fuzz.yml @@ -0,0 +1,296 @@ +name: Nightly Fuzz + +# Two modes, on purpose. +# +# Every pull request already runs the fuzz suite through `npm test`, with a fixed +# seed and small per-target budgets. That run is deterministic: it cannot fail +# because of an unlucky draw, which is the only way a fuzz suite survives contact +# with a CI system people have to trust. +# +# This job is where the searching happens. It varies the seed per run, raises the +# budgets, and enforces the parts of the known-divergence registry that would be +# hostile on a pull request — entries past their review date, and entries that no +# longer excuse anything. + +on: + schedule: + # 03:17 UTC, off the hour so it does not queue behind everything else. + - cron: '17 3 * * *' + workflow_dispatch: + inputs: + seed: + description: 'Fuzz seed (defaults to the run id)' + required: false + type: string + mode: + description: 'smoke or deep' + required: false + default: 'deep' + # A choice, not free text: the runner rejects anything else outright, and + # a rejected dispatch is better than one that silently runs a smoke pass. + type: choice + options: + - deep + - smoke + +# One fuzz run at a time in the *repository*, not per ref. The tracking issue is +# repository-wide, so two runs on different refs — a nightly and a manual +# dispatch on a branch — would each look it up, each find nothing, and each +# create one. Every later run then comments on and closes only the lowest +# number, leaving the duplicate open forever holding a stale report. Queued +# rather than cancelled: the in-flight run's result is the one worth keeping. +concurrency: + group: fuzz + cancel-in-progress: false + +permissions: + contents: read + issues: write + +jobs: + fuzz: + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '24' + cache: 'npm' + + - name: Install dependencies + run: npm ci + + - name: Run the fuzz suite + id: fuzz + env: + # A fresh seed per run is the point: the fixed-seed smoke run on every + # PR has already searched its own corner exhaustively. + FUZZ_SEED: ${{ inputs.seed || github.run_id }} + FUZZ_MODE: ${{ inputs.mode || 'deep' }} + FUZZ_TIME_BUDGET_MS: '180000' + FUZZ_REPORT_DIR: fuzz-reports + FUZZ_STRICT_ALLOWLIST: '1' + # --expose-gc turns on the WASM handle-leak probe, which skips without it. + # --test-timeout bounds a decoder that stops returning: the runner's own + # slowMs is measured after the check completes, so it cannot see an input + # that never completes. The parent process owns this timer, so it fires + # even when the child's event loop is blocked by a synchronous WASM loop. + run: node --expose-gc --test --test-timeout=1800000 "./src/__fuzz__/**/*.test.ts" + continue-on-error: true + + - name: Summarise + id: report + if: always() + run: | + { + node scripts/fuzz/report.ts fuzz-reports --markdown --fail-on-stale \ + ${{ steps.fuzz.outcome == 'success' && ' ' || '--run-failed' }} + } > fuzz-summary.md 2>&1 && echo "clean=true" >> "$GITHUB_OUTPUT" || echo "clean=false" >> "$GITHUB_OUTPUT" + cat fuzz-summary.md >> "$GITHUB_STEP_SUMMARY" + + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: fuzz-reports-${{ github.run_id }} + path: | + fuzz-reports/ + fuzz-summary.md + retention-days: 30 + + # The fuzz outcome is part of the condition, not just the report: a run can + # fail on something the report has no findings for — a harness crash, a + # suite-level assertion — and an issue is exactly what those need too. + # + # Runs on every outcome, not only failures. A clean run has something to + # record too — but only that, and it never closes the issue: a different + # seed reaching nothing is not evidence the finding is gone. Closing is a + # person's call, made against the reproducer in the report. + - name: Update the tracking issue + if: always() + uses: actions/github-script@v7 + with: + script: | + const fs = require('node:fs') + const summary = fs.readFileSync('fuzz-summary.md', 'utf8') + // The seed is a free-form workflow input, so the reproduction command + // has to quote it or it is not the command that ran: `nightly run` + // would make the shell treat `run` as the program, and a `;` would + // append a second command to whatever the reader pastes. Same rule as + // the runner's own replay hint. + const shellQuote = value => + /^[\w.:@/+=-]+$/.test(value) ? value : `'${String(value).replaceAll("'", String.raw`'\''`)}'` + // The same condition the step used to be gated on, now a value: the + // report found nothing *and* the run itself did not fail. + const clean = ${{ steps.report.outputs.clean == 'true' && steps.fuzz.outcome == 'success' }} + const seed = process.env.FUZZ_SEED + const mode = process.env.FUZZ_MODE + const budget = process.env.FUZZ_TIME_BUDGET_MS + // Stable across runs, so the issue this job owns is identifiable by + // something other than a label anyone can apply. The seed moved into + // the body: it changes nightly, and a title that changes cannot be a key. + const title = 'Nightly fuzz: open findings' + const marker = '' + // Marks a clean-run note, so those can be counted and capped without + // mistaking them for the reports they are attached to. + const cleanMarker = '' + + // One open issue per topic, updated rather than duplicated: a fuzzer + // that opens a fresh issue every night trains people to close them + // unread. + // + // Three things have to agree before this job will write to an issue: + // the marker in the body, the title it files under, and an author that + // is this workflow's own bot identity. + // + // The marker is public — it is visible in every report this job posts — + // so anyone who can open an issue can paste it. On its own it is a claim + // of ownership, not proof of one: an issue carrying it with a lower + // number would collect the nightly's reports, and a clean night would + // comment on and close it. Authorship is the part a repository user + // cannot forge, so it is the part that decides. + // + // `listForRepo` returns pull requests as well as issues, and the generic + // `fuzz` label is one anybody can put on anything — so the label by + // itself would let the nightly report land in an unrelated thread, or on + // a PR, and the API's default ordering makes which one unstable as more + // labelled items are opened. + // + // And the label cannot be part of the *query* either, only of the + // answer. A label is editable by anyone with write access: strip `fuzz` + // from the open tracking issue and a label-scoped search stops returning + // it before the marker is ever consulted, so the next failing night files + // a duplicate and no clean night can ever find and close the original. + // The marker lives in the body, which is the one part of the issue this + // job writes and nothing routine edits. It still *applies* the label on + // creation, for people who browse that way — it just never trusts it. + // + // The cost is listing open issues rather than a label slice, which is + // why it paginates. The lowest number wins, so the choice does not depend + // on page order either. + const open = await github.paginate(github.rest.issues.listForRepo, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100 + }) + // `github-actions[bot]` is who `GITHUB_TOKEN` posts as, which is what + // creates the issue below. A repository user cannot author as it. + const owned = open + .filter( + item => + !item.pull_request && + (item.body ?? '').includes(marker) && + item.title === title && + item.user?.type === 'Bot' && + item.user?.login === 'github-actions[bot]' + ) + .sort((left, right) => left.number - right.number) + + const body = [ + marker, + `Seed: \`${seed}\` · mode \`${mode}\``, + '', + summary, + '', + `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + // The run's own flags, or the reproduction can pass where the run + // failed: without --expose-gc the leak probe skips, without + // FUZZ_STRICT_ALLOWLIST an expired entry is a warning rather than a + // failure, and the budget decides how many inputs each target gets + // through before it truncates. The mode is read back rather than + // hard-coded to `deep`, because a manual dispatch can run `smoke` + // and `npm run fuzz:deep` would then reproduce a different run. + `Reproduce locally: \`FUZZ_SEED=${shellQuote(seed)} FUZZ_MODE=${mode} FUZZ_TIME_BUDGET_MS=${budget} FUZZ_STRICT_ALLOWLIST=1 node --expose-gc --test --test-timeout=1800000 "./src/__fuzz__/**/*.test.ts"\``, + '', + '---', + '_Generated by [Claude Code](https://claude.ai/code)_' + ].join('\n') + + // A clean run records itself on the issue. It does not close it. + // + // Closing was wrong, and wrong in the direction that loses bugs. Each + // night draws a different seed, so a clean run samples different inputs + // than the one that filed the report — it never replays the failing + // input, because the workflow does not set `FUZZ_RECORD` and so nothing + // freezes a reproducer into the corpus. A manual `mode: smoke` dispatch + // is worse again: roughly a twenty-fifth of the inputs, and it could + // close a finding the nightly's deep run had taken 4,000 iterations to + // reach. "Tonight's seed did not hit it" is not "it is fixed", and an + // unresolved regression silently leaving the tracker is the one outcome + // this issue exists to prevent. + // + // So a clean night appends evidence and leaves the issue open. Whoever + // reads it decides — after replaying the reproducer, or after the fix + // lands — and closing by hand is deliberate rather than incidental. The + // comment is capped: the point is a record, not a nightly heartbeat that + // buries the report under a year of "still clean". + if (clean) { + for (const issue of owned) { + const comments = await github.paginate(github.rest.issues.listComments, { + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + per_page: 100 + }) + const cleanRuns = comments.filter(item => (item.body ?? '').includes(cleanMarker)).length + if (cleanRuns >= 5) continue + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: issue.number, + body: [ + marker, + cleanMarker, + `The nightly is clean on seed \`${seed}\` (mode \`${mode}\`).`, + '', + 'This does **not** close the issue. Each run draws a different seed and the failing input is not replayed, so a clean night says the finding was not reached — not that it was fixed. Close this by hand once the reproducer above no longer reproduces, or once the fix lands.', + '', + `Run: ${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`, + '', + '---', + '_Generated by [Claude Code](https://claude.ai/code)_' + ].join('\n') + }) + } + return + } + + if (owned.length > 0) { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: owned[0].number, + body + }) + } else { + await github.rest.issues.create({ + owner: context.repo.owner, + repo: context.repo.repo, + title, + body, + labels: ['fuzz'] + }) + } + env: + FUZZ_SEED: ${{ inputs.seed || github.run_id }} + # Mirrored from the run step, so the reproduction command names the mode + # and budget the findings were actually produced under. + FUZZ_MODE: ${{ inputs.mode || 'deep' }} + FUZZ_TIME_BUDGET_MS: '180000' + + # The fuzz and summarise steps deliberately do not fail on the spot, so the + # artifacts get uploaded and the issue gets filed first. Without a final gate + # the job would then finish green holding findings, which is the one outcome + # that would make the whole nightly pointless. + - name: Fail the job when the run was not clean + if: always() && (steps.fuzz.outcome != 'success' || steps.report.outputs.clean != 'true') + run: | + echo "fuzz outcome: ${{ steps.fuzz.outcome }}" + echo "report clean: ${{ steps.report.outputs.clean }}" + exit 1 diff --git a/README.md b/README.md index 1a46d8c4..e48394f6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,14 @@ so existing integrations can migrate with minimal changes. See | Key management | JS auth state | Rust `PersistenceManager` | | Auto-reconnect | Manual `startSock()` loop | Transient drops retried in Rust (fibonacci backoff); terminal ones still yours | +Compatibility is checked rather than assumed: a declaration audit against +upstream's `.d.ts`, a wire-fidelity audit of the send path, ~50 behavioural +compatibility suites, and a +[differential fuzz suite](src/__fuzz__/README.md) that generates its own inputs +from the proto schema and compares the two libraries directly. Differences the +fuzzers find are recorded with a reason and a review date, and known open ones +are listed in `src/__fuzz__/harness/divergence.ts`. + ## Documentation The full API reference and guides live in the diff --git a/package.json b/package.json index 1829c4dc..45c54340 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "lib/**/*", "!lib/**/*.map", "!lib/**/__tests__/**", + "!lib/__fuzz__/**", "!lib/**/*.test.*", "!lib/**/*.test-e2e.*" ], @@ -70,6 +71,10 @@ "prepack": "npm run build && node scripts/check-pack.ts", "prepare": "npm run build", "test": "node --test", + "fuzz": "node --test --test-timeout=600000 ./src/__fuzz__/**/*.test.ts", + "fuzz:deep": "FUZZ_MODE=deep node --expose-gc --test --test-timeout=1800000 ./src/__fuzz__/**/*.test.ts", + "fuzz:record": "FUZZ_RECORD=1 node --test --test-timeout=600000 ./src/__fuzz__/**/*.test.ts", + "fuzz:report": "node scripts/fuzz/report.ts", "test:compat-auditor": "node --test scripts/compatibility/__tests__/audit.test.ts", "typecheck:compat-auditor": "npm run build --silent && npm run compat:check-waproto --silent && npm run compat:layers --silent && tsc -p scripts/compatibility/tsconfig.json", "test:e2e": "NODE_TLS_REJECT_UNAUTHORIZED=0 ADV_SECRET_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA= node --expose-gc --test --test-concurrency=1 ./src/__tests__/e2e/*.test-e2e.ts" diff --git a/scripts/fuzz/report.ts b/scripts/fuzz/report.ts new file mode 100644 index 00000000..b5960b6c --- /dev/null +++ b/scripts/fuzz/report.ts @@ -0,0 +1,277 @@ +#!/usr/bin/env node + +/** + * Aggregates the per-target JSON reports a fuzz run writes into FUZZ_REPORT_DIR. + * + * The nightly job needs three things a test runner cannot give it. First, one + * readable summary instead of eight TAP streams. Second, the *stale* entries in + * the known-divergence registry — an entry that excused nothing across a whole + * deep run is either fixed or no longer reachable, and either way it should be + * deleted rather than left to excuse a future regression. That question can only + * be answered across targets, and `node --test` runs each file in its own + * process. Third, an issue body worth opening. + * + * Usage: + * FUZZ_REPORT_DIR=./fuzz-reports npm run fuzz:deep + * node scripts/fuzz/report.ts ./fuzz-reports [--markdown] [--fail-on-stale] [--run-failed] + */ + +import { existsSync, readFileSync, readdirSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { KNOWN_DIVERGENCES, staleEntries } from '../../src/__fuzz__/harness/divergence.ts' + +interface Report { + target: string + seed: string + mode: string + runs: number + corpusReplayed: number + excused: number + excusedBy?: string[] + openFindings?: string[] + truncated?: { ran: number; planned: number } + findings: { target: string; detail?: string }[] + crashes?: string[] + /** + * The real crash count. `crashes` holds a capped, deduplicated sample — a + * systemic failure throws once per input and the runner stores five details for + * it — so the array length understates a run by orders of magnitude. Older + * reports carry no such field, hence the fallback. + */ + crashCount?: number +} + +/** + * A value the reader can paste into a shell and get back verbatim. + * + * The seed reaches here from the report files, which take it from a free-form + * workflow input. The runner's replay hint and the tracking-issue body already + * quote it; this summary is embedded verbatim in that same issue, so leaving it + * unquoted advertised the unsafe command right beside the safe one. Single + * quotes rather than JSON, because double quotes still expand `$` and backticks. + */ +const shellQuote = (value: string): string => + /^[\w.:@/+=-]+$/u.test(value) ? value : `'${value.replaceAll("'", String.raw`'\''`)}'` + +const argv = process.argv.slice(2) +const directory = resolve( + argv.find(argument => !argument.startsWith('--')) ?? process.env.FUZZ_REPORT_DIR ?? 'fuzz-reports' +) +const markdown = argv.includes('--markdown') +const failOnStale = argv.includes('--fail-on-stale') +/** + * Set by the caller when the fuzz step itself did not exit cleanly. + * + * A file that dies during import or on a suite-level assertion writes no report + * at all, and a report that was never written cannot be marked truncated — so + * the aggregate looks complete and every entry those targets would have excused + * gets called stale. That turns one unrelated crash into a recommendation to + * delete working allowlist entries. + */ +const runFailed = argv.includes('--run-failed') + +if (!existsSync(directory)) { + console.error(`fuzz report: no such directory ${directory}`) + console.error('Run the suite with FUZZ_REPORT_DIR set to collect per-target reports first.') + process.exit(2) +} + +const reports: Report[] = readdirSync(directory) + .filter(name => name.endsWith('.json')) + .map(name => JSON.parse(readFileSync(join(directory, name), 'utf8')) as Report) + .toSorted((left, right) => left.target.localeCompare(right.target)) + +if (reports.length === 0) { + console.error(`fuzz report: ${directory} holds no reports`) + process.exit(2) +} + +const totals = reports.reduce( + (accumulator, report) => ({ + runs: accumulator.runs + report.runs, + corpus: accumulator.corpus + report.corpusReplayed, + excused: accumulator.excused + report.excused, + findings: accumulator.findings + report.findings.length, + crashes: accumulator.crashes + (report.crashCount ?? report.crashes?.length ?? 0) + }), + { runs: 0, corpus: 0, excused: 0, findings: 0, crashes: 0 } +) + +const truncated = reports.filter(report => report.truncated) +const seeds = [...new Set(reports.map(report => report.seed))] +const mode = reports[0]?.mode ?? 'smoke' + +/** + * Registry entries that excused nothing anywhere in this run. + * + * Each report names the exact entry ids that fired, which is why this can be + * stated rather than guessed. It is the one question a per-file test runner + * cannot answer on its own: `node --test` gives every file its own process, so + * no single run sees the whole registry being exercised. + * + * An entry that excuses nothing is either fixed or no longer reachable. Both are + * reasons to delete it — an allowlist that outlives its divergence is how the + * same bug comes back unnoticed. + */ +const used = [...new Set(reports.flatMap(report => report.excusedBy ?? []))] +// A truncated target may simply not have reached the input that would have used +// an entry, so "excused nothing" proves nothing this run. Expired entries are +// still enforced — that check does not depend on coverage. +// A target filter has the same effect as a crash on this question: the targets +// it skipped wrote no report, so an entry only they would have used looks unused. +// +// Read from the reports, not from `process.env`: the filter is consumed by the +// runner, and `FUZZ_ONLY=x npm run fuzz:deep` followed by a separate `node +// scripts/fuzz/report.ts` leaves it unset here. A skipped target writes a report +// with `runs: 0`, which survives the process boundary. +const filtered = + Boolean(process.env.FUZZ_ONLY?.trim()) || reports.some(report => report.runs === 0 && !report.truncated) +// And a smoke run is the same question again, from sample size rather than from +// coverage: it draws roughly a twenty-fifth of the inputs deep mode does — 201 +// against 5001 on `pure:cleanMessage`, measured — so an entry it never reached +// is not thereby unreachable. Nothing else about smoke changes; findings, +// crashes and expired entries still fail exactly as before. +// +// Read from the reports rather than from the mode this process was told about, +// for the same reason the filter is: the runner owns `FUZZ_MODE`, and a separate +// `node scripts/fuzz/report.ts` invocation does not see it. +const sampled = mode !== 'deep' +const complete = truncated.length === 0 && !runFailed && !filtered && !sampled +const candidates = complete ? staleEntries(used) : [] + +const today = new Date().toISOString().slice(0, 10) +const expired = KNOWN_DIVERGENCES.filter(entry => entry.review < today) +const open = KNOWN_DIVERGENCES.filter(entry => entry.status === 'open') + +const lines: string[] = [] +const heading = (text: string) => lines.push(markdown ? `## ${text}` : `\n${text}`) + +lines.push(markdown ? '# Fuzz run' : 'fuzz run') +lines.push( + `mode ${mode} · seed${seeds.length > 1 ? 's' : ''} ${seeds.join(', ')} · ${totals.runs} inputs across ${reports.length} targets` +) +lines.push( + `${totals.findings} unexcused finding(s) · ${totals.crashes} crash(es) · ${totals.excused} excused · ${totals.corpus} replayed from the corpus` +) + +if (truncated.length > 0) { + heading('Truncated targets') + lines.push('These did not finish inside their time budget, so their coverage is partial:') + for (const report of truncated) { + lines.push(`- ${report.target}: ${report.truncated!.ran} of ${report.truncated!.planned}`) + } + if (failOnStale) { + lines.push('') + lines.push( + 'Under `--fail-on-stale` this fails the run. A target that silently shrinks to a prefix reports a pass ' + + 'having searched a fraction of what it claims — which is how a slow regression hides. Either the budget ' + + 'is too low for the machine, or something got slower; both need a person.' + ) + } +} + +if (totals.findings > 0) { + heading('Unexcused findings') + for (const report of reports) { + if (report.findings.length === 0) continue + lines.push(`- **${report.target}** — ${report.findings.length}`) + const details = [...new Set(report.findings.map(finding => finding.detail ?? 'no detail'))] + for (const detail of details.slice(0, 5)) lines.push(` - ${detail}`) + } + lines.push('') + // The mode is part of the reproduction, not decoration. `npm run fuzz` sets no + // `FUZZ_MODE` and so runs smoke — roughly a twenty-fifth of the inputs — which + // replays the same deterministic prefix and can finish clean before reaching a + // finding the nightly's deep run only got to on its 4000th input. Right seed, + // right target, wrong answer. + lines.push( + `Reproduce with \`FUZZ_SEED=${shellQuote(String(seeds[0] ?? ''))} FUZZ_ONLY="" npm run ${mode === 'deep' ? 'fuzz:deep' : 'fuzz'}\`.` + ) +} + +// A crash is a failure with no divergence attached — a check or generator that +// threw, or an expired allowlist entry under strict mode. Without this section a +// crash-only run printed "0 findings" while the job went red, which reads as a +// broken workflow rather than a real result. +if (totals.crashes > 0) { + heading('Crashes') + // Bounded and deduplicated. A check that starts throwing systematically records + // one crash per generated input, and this body is used verbatim as the GitHub + // issue — thousands of near-identical lines would push it past the size limit + // and lose the notification exactly when the harness is most broken. + const LIMIT = 25 + const seen = new Set() + let shown = 0 + let omitted = 0 + for (const report of reports) { + for (const crash of report.crashes ?? []) { + // Keyed on the `threw` line, not the first one. The first line carries the + // origin — `seed X, run 412` — so a check that throws systematically + // produced a distinct key for every input, defeating the dedup entirely: + // one failure consumed the whole cap while later crash classes went + // unlisted. The exception text is what actually identifies a crash. + const threw = crash + .split('\n') + .map(line => line.trim()) + .find(line => line.startsWith('threw ')) + const key = `${report.target}\u0000${threw ?? crash.trim().split('\n')[0]}` + if (seen.has(key) || shown >= LIMIT) { + omitted++ + continue + } + seen.add(key) + shown++ + lines.push(`- **${report.target}** — ${crash.trim()}`) + } + } + if (omitted > 0) lines.push(`- …and ${omitted} more (repeats of the above, or past the ${LIMIT}-line cap)`) +} + +if (open.length > 0) { + heading('Open findings still on the books') + for (const entry of open) lines.push(`- \`${entry.id}\` (review by ${entry.review})`) +} + +if (expired.length > 0) { + heading('Known divergences past review') + for (const entry of expired) lines.push(`- \`${entry.id}\` was due ${entry.review} — re-argue it or delete it`) +} + +if (!complete) { + heading('Registry stale-entry check skipped') + lines.push( + runFailed + ? 'The fuzz step did not exit cleanly, so some targets may have written no report at all and this run cannot establish which entries are unused.' + : filtered + ? 'FUZZ_ONLY selected a subset of targets, so this run cannot establish which entries are unused.' + : truncated.length > 0 + ? 'At least one target was truncated, so this run cannot establish which entries are unused.' + : `This was a ${mode} run, which draws a fraction of the inputs deep mode does, so it cannot establish which entries are unused. Run \`npm run fuzz:deep\` to answer that.` + ) +} else if (candidates.length > 0) { + heading('Registry entries that excused nothing') + lines.push('These excused nothing anywhere in this run — fixed, or no longer reachable. Either way, delete them:') + for (const entry of candidates) lines.push(`- \`${entry.id}\``) +} + +console.log(lines.join('\n')) + +/** + * Truncation fails the run, but only under `--fail-on-stale`. + * + * The runner only warns about it, and a warning is not enough on its own: a + * target reduced to an arbitrarily small prefix still writes a report with no + * findings, so a regression that makes a decoder ten times slower shows up as a + * green run with less coverage rather than as a failure. + * + * Gated on the same flag as the other strict checks because the two callers want + * different things. The nightly passes it and gives every target 180s, so + * truncation there means something actually got slower. A local `npm run fuzz` + * has a 6s smoke budget and truncates routinely on a busy machine; failing that + * would train people to ignore the signal. + */ +const failed = + totals.findings > 0 || + totals.crashes > 0 || + (failOnStale && (expired.length > 0 || candidates.length > 0 || truncated.length > 0)) +process.exit(failed ? 1 : 0) diff --git a/src/Utils/event-buffer.ts b/src/Utils/event-buffer.ts index c0a3d8c7..47f50770 100644 --- a/src/Utils/event-buffer.ts +++ b/src/Utils/event-buffer.ts @@ -181,7 +181,14 @@ const append = ( case 'chats.upsert': { for (const chat of eventData as Chat[]) { const id = chat.id || '' - let existing = data.chatUpserts[id] || data.historySets.chats[id] + // The history set is only consulted for a chat that *has* an id. + // Upstream guards the lookup with `id &&`, and this port had dropped + // it: an id-less chat then folded into whatever id-less entry a + // buffered history set happened to carry, summing their unread counts, + // where upstream releases it as its own `chats.upsert`. Found by the + // buffer differential once history rows started drawing from the same + // identity pool as live traffic. + let existing = data.chatUpserts[id] || (id ? data.historySets.chats[id] : undefined) if (existing) concatChats(existing, chat) else { existing = chat diff --git a/src/__fuzz__/README.md b/src/__fuzz__/README.md new file mode 100644 index 00000000..2945851f --- /dev/null +++ b/src/__fuzz__/README.md @@ -0,0 +1,133 @@ +# Fuzzing + +Differential and property-based fuzzing of baileyrs against upstream Baileys. + +## Why this exists + +The repository already compares itself to Baileys in three ways, and all three +use inputs somebody wrote down: + +| Layer | Where | What it compares | +| ------------ | --------------------------------------------- | ------------------------------------------- | +| Declarations | `scripts/compatibility/audit-core.ts` | `.d.ts` shapes against `baileys` | +| Send path | `scripts/compatibility/wire-fidelity-core.ts` | planted proto fields survive `relayMessage` | +| Behaviour | ~50 `src/**/*-compatibility.test.ts` | fixed fixtures against `import('baileys')` | + +They find what someone thought to write down. This directory generates the +inputs instead — from the proto schema, from the bridge event table, from a JID +grammar — and asks whether the two libraries still agree. + +The first runs found 21 differences: 18 still open, 3 deliberate. They are +recorded in `harness/divergence.ts` with a reason and a review date, and the +ones with a minimised reproducer carry it in `corpus/`. + +## Running it + +```sh +npm test # included; fixed seed, small budgets, deterministic +npm run fuzz # just the fuzz suite +npm run fuzz:deep # bigger budgets, longer, plus the WASM leak probe +``` + +A failure prints the seed, the minimised input, both results, and the command to +replay it. + +### Environment + +| Variable | Meaning | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| `FUZZ_SEED` | Seed string. Default `baileyrs-fuzz-v1`. | +| `FUZZ_RUNS` | Iterations per target. Ignored by targets marked `exhaustive`, which sweep a finite set and would otherwise report a partial pass as a full one. | +| `FUZZ_MODE` | `smoke` (default) or `deep`. | +| `FUZZ_TIME_BUDGET_MS` | Per-target wall-clock ceiling. | +| `FUZZ_ONLY` | Substring filter over target names, for triage. | +| `FUZZ_RECORD` | `1` appends minimised failures to the corpus. | +| `FUZZ_STRICT_ALLOWLIST` | `1` fails on registry entries past review. | +| `FUZZ_REPORT_DIR` | Directory for per-target JSON reports. | + +## The targets + +| File | Asks | +| -------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `pure-differential.fuzz.test.ts` | do the shared pure helpers agree with upstream, on values and on throwing (the count lives in `targets.ts`) | +| `proto-codec.fuzz.test.ts` | do the Rust/WASM codec and protobufjs agree, across all 498 message types | +| `proto-robustness.fuzz.test.ts` | what does the decoder do with bytes a hostile peer chose | +| `wire-fidelity.fuzz.test.ts` | does `relayMessage` hand the bridge everything the message carried | +| `bridge-events.fuzz.test.ts` | does the anti-corruption layer drop what it cannot parse, and does the buffer lose events | +| `argument-boundary.fuzz.test.ts` | is an off-domain argument rejected before it reaches WASM, with a usable stack | +| `coverage.fuzz.test.ts` | is every shared export either fuzzed or excused in writing | + +## Design + +**Determinism first.** Nothing here calls `Math.random`. A failure nobody can +replay is a failure nobody can fix, so `npm test` runs a fixed seed and the +nightly job varies it. + +**Shrink before reporting.** A 400-node generated message that diverges is a +haystack. Every failing input is minimised first — usually to two or three +fields. + +**A corpus, not luck.** Minimised failures are committed to `corpus/` and +replayed before any fresh generation, so a fixed bug stays fixed when the seed +moves on. + +**Findings are recorded, not muted.** `harness/divergence.ts` separates +`intended` (baileyrs is deliberately different, and here is why) from `open` (a +real difference nobody has decided about). Open entries keep the suite green so +the next run does not re-report them as news, and they are printed on _every_ +run so they cannot quietly become "fine". Both carry a review date; past it the +nightly job fails. + +**Classification over volume.** Field ordering and packed-vs-unpacked repeated +scalars are legal protobuf and differ on nearly every message. They are their own +targets, so excusing them never blinds the checks that matter. Likewise, a +difference that is structurally a _subset_ is reported as an omission, which +means "the bridge dropped a field" can never share an allowlist entry with "the +bridge wrote a different value". + +**Coverage is a claim, not a hope.** `targets.ts` accounts for every shared +export — fuzzed, or excused with a reason. A new export fails the suite until +someone decides which it is. `argument-boundary.fuzz.test.ts` does the same by +scanning the source for `assertArgumentDomain` call sites. + +**No silent caps.** A target that runs out of time says so, and finite sweeps +are marked `exhaustive` so the budget cannot truncate them. A run that checked +747 of 1734 inputs and printed nothing reads exactly like one that checked +them all — that happened during development, and the warning exists because of it. + +## What this does not cover + +Two lists of known gaps, and this section exists because neither was findable +from outside the source. The `open` entries in `harness/divergence.ts` are the +first: real differences nobody has decided about, printed on every run and +carrying a review date. This is the second — surfaces the suite does not reach +at all, which no run will ever remind anyone about. + +| Surface | Why it is not fuzzed | +| --------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `createBufferedFunction` | A differential was written and withdrawn. It surfaced consolidation differences that are real findings needing characterisation — `groups.update` entries with no `id`, and blank fields kept on one side but not the other — and landing it would have meant an allowlist entry broad enough to excuse whatever else turned up. `Socket/groups.ts` and `Socket/internals.ts` use it in production, so this is worth closing. | +| `generateWAMessage`, `generateWAMessageContent` | Both are async and reach media upload. A differential needs a paired upload stub that returns identically on both sides first, or every draw diverges on an upload URL. Only the synchronous `generateWAMessageFromContent` is covered, by `wire-fidelity.fuzz.test.ts`. | +| Keyed crypto (`decryptPollVote`, `decryptEventResponse`, `decryptMediaRetryData`) | Random input only ever reaches the shared reject branch, so the differential proves nothing. Needs real key material fixtures. | + +`targets.ts` carries the same reasons per export, since that is where the +suite enforces them; this table is the version somebody planning work can find. + +## Adding a target + +```ts +await fuzz({ + target: 'area:property', // names the corpus file and the allowlist key + runs: 200, + generate: random => ..., // consume `random` only + check: input => { // return the differences; [] means agreement + ... + return { target: 'area:property', input, local, upstream, detail: '...' } + } +}) +``` + +Two rules. `check` must be **total** over the shrinker's candidate space — it +proposes `{}` and `undefined`, and a property that throws on those reports a +crash in the fuzzer instead of a finding in the library. And a target that +answers a finite question should set `exhaustive: true` and iterate rather than +sample. diff --git a/src/__fuzz__/argument-boundary.fuzz.test.ts b/src/__fuzz__/argument-boundary.fuzz.test.ts new file mode 100644 index 00000000..4f0be092 --- /dev/null +++ b/src/__fuzz__/argument-boundary.fuzz.test.ts @@ -0,0 +1,659 @@ +/** + * The public argument boundary, fuzzed. + * + * `src/__tests__/closed-domain-arguments.test.ts` documents why this boundary + * exists, from a production report: `groupParticipantsUpdate(from, [id], '☠️')` + * called fire-and-forget, the value crossing into the bridge untouched, and the + * consumer getting an `unhandledRejection` whose every frame reads + * `wasm://wasm/`. Nothing in it points at the line that made the call. + * + * That test checks the contract for values somebody chose. This one generates + * them — every off-domain shape a JavaScript caller can produce, not just the + * near-misses — and asserts the same contract on all of them: + * + * - the rejection is a Boom carrying statusCode 400 + * - it names the parameter and lists what is accepted, in `data` + * - its message shows what actually arrived + * - **its stack contains no `wasm://` frames** + * + * The last is the one that matters and the one only a real socket can answer, so + * these drive the real public socket pointed at a port nothing listens on: a + * value that passes validation fails downstream as "not connected" rather than + * reaching a server, which is exactly the distinction being tested — + * rejected-by-us versus not-rejected-by-us. + * + * A source scan keeps the table honest: every `assertArgumentDomain` call site in + * `src/` must appear below, so guarding a new parameter without fuzzing it fails + * the suite. + */ + +import assert from 'node:assert/strict' +import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import path from 'node:path' +import { after, before, describe, it } from 'node:test' + +import makeWASocket from '../Socket/index.ts' +import { useMultiFileAuthState } from '../Utils/use-multi-file-auth-state.ts' +import type { Divergence } from './harness/divergence.ts' +import { fuzz } from './harness/runner.ts' +import type { Random } from './harness/random.ts' +import { generateNumber, generateString } from './generators/values.ts' + +type Socket = ReturnType + +/** Pass an off-union value through a typed parameter, the way plain JS does. */ +const off = (value: unknown): T => value as T + +const GROUP = '120363000000000000@g.us' +const USER = '15550000000@s.whatsapp.net' + +interface BoundaryCase { + /** The method name the rejection must use. */ + readonly method: string + /** The parameter name the rejection must name. */ + readonly parameter: string + /** `path/to/file.ts:method:parameter`, relative to `src/`, matching the scan. */ + readonly source: string + readonly call: (socket: Socket, value: unknown) => Promise +} + +const CASES: readonly BoundaryCase[] = [ + { + method: 'updateBlockStatus', + parameter: 'action', + source: 'Socket/blocking.ts:updateBlockStatus:action', + call: (s, v) => s.updateBlockStatus(USER, off(v)) + }, + { + method: 'sendPresenceUpdate', + parameter: 'type', + source: 'Socket/index.ts:sendPresenceUpdate:type', + call: (s, v) => s.sendPresenceUpdate(off(v), USER) + }, + { + method: 'waUploadToServer', + parameter: 'mediaType', + source: 'Socket/index.ts:waUploadToServer:mediaType', + call: (s, v) => + s.waUploadToServer(off(Buffer.from('x')), off({ mediaType: v, fileEncSha256B64: '', mediaType2: undefined })) + }, + { + method: 'groupSettingUpdate', + parameter: 'setting', + source: 'Socket/groups.ts:groupSettingUpdate:setting', + call: (s, v) => s.groupSettingUpdate(GROUP, off(v)) + }, + { + method: 'groupRequestParticipantsUpdate', + parameter: 'action', + source: 'Socket/groups.ts:groupRequestParticipantsUpdate:action', + call: (s, v) => s.groupRequestParticipantsUpdate(GROUP, [USER], off(v)) + }, + { + method: 'groupParticipantsUpdate', + parameter: 'action', + source: 'Socket/groups.ts:groupParticipantsUpdate:action', + call: (s, v) => s.groupParticipantsUpdate(GROUP, [USER], off(v)) + }, + { + method: 'groupMemberAddMode', + parameter: 'mode', + source: 'Socket/groups.ts:groupMemberAddMode:mode', + call: (s, v) => s.groupMemberAddMode(GROUP, off(v)) + }, + { + method: 'groupJoinApprovalMode', + parameter: 'mode', + source: 'Socket/groups.ts:groupJoinApprovalMode:mode', + call: (s, v) => s.groupJoinApprovalMode(GROUP, off(v)) + }, + { + method: 'sendReceipt', + parameter: 'type', + source: 'Socket/messages.ts:sendReceipt:type', + call: (s, v) => s.sendReceipt(USER, undefined, ['ABC'], off(v)) + }, + { + method: 'sendReceipts', + parameter: 'type', + source: 'Socket/messages.ts:sendReceipts:type', + call: (s, v) => s.sendReceipts([{ remoteJid: USER, id: 'ABC', fromMe: false }], off(v)) + }, + { + method: 'newsletterMetadata', + parameter: 'type', + source: 'Socket/newsletter.ts:newsletterMetadata:type', + call: (s, v) => s.newsletterMetadata(off(v), 'key') + }, + { + method: 'cleanDirtyBits', + parameter: 'type', + source: 'Socket/server-queries.ts:cleanDirtyBits:type', + call: (s, v) => s.cleanDirtyBits(off(v)) + }, + { + method: 'sendPresence', + parameter: 'status', + source: 'Socket/presence.ts:sendPresence:status', + call: (s, v) => s.sendPresence(off(v)) + }, + { + method: 'sendChatState', + parameter: 'state', + source: 'Socket/presence.ts:sendChatState:state', + call: (s, v) => s.sendChatState(USER, off(v)) + }, + { + method: 'updateLastSeenPrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateLastSeenPrivacy:value', + call: (s, v) => s.updateLastSeenPrivacy(off(v)) + }, + { + method: 'updateOnlinePrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateOnlinePrivacy:value', + call: (s, v) => s.updateOnlinePrivacy(off(v)) + }, + { + method: 'updateProfilePicturePrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateProfilePicturePrivacy:value', + call: (s, v) => s.updateProfilePicturePrivacy(off(v)) + }, + { + method: 'updateStatusPrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateStatusPrivacy:value', + call: (s, v) => s.updateStatusPrivacy(off(v)) + }, + { + method: 'updateReadReceiptsPrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateReadReceiptsPrivacy:value', + call: (s, v) => s.updateReadReceiptsPrivacy(off(v)) + }, + { + method: 'updateGroupsAddPrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateGroupsAddPrivacy:value', + call: (s, v) => s.updateGroupsAddPrivacy(off(v)) + }, + { + method: 'updateCallPrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateCallPrivacy:value', + call: (s, v) => s.updateCallPrivacy(off(v)) + }, + { + method: 'updateMessagesPrivacy', + parameter: 'value', + source: 'Socket/privacy.ts:updateMessagesPrivacy:value', + call: (s, v) => s.updateMessagesPrivacy(off(v)) + }, + { + method: 'profilePictureUrl', + parameter: 'type', + source: 'Socket/contacts.ts:profilePictureUrl:type', + call: (s, v) => s.profilePictureUrl(USER, off(v)) + }, + { + method: 'communityRequestParticipantsUpdate', + parameter: 'action', + source: 'Socket/communities.ts:communityRequestParticipantsUpdate:action', + call: (s, v) => s.communityRequestParticipantsUpdate(GROUP, [USER], off(v)) + }, + { + method: 'communityParticipantsUpdate', + parameter: 'action', + source: 'Socket/communities.ts:communityParticipantsUpdate:action', + call: (s, v) => s.communityParticipantsUpdate(GROUP, [USER], off(v)) + }, + { + method: 'communitySettingUpdate', + parameter: 'setting', + source: 'Socket/communities.ts:communitySettingUpdate:setting', + call: (s, v) => s.communitySettingUpdate(GROUP, off(v)) + }, + { + method: 'communityMemberAddMode', + parameter: 'mode', + source: 'Socket/communities.ts:communityMemberAddMode:mode', + call: (s, v) => s.communityMemberAddMode(GROUP, off(v)) + }, + { + method: 'communityJoinApprovalMode', + parameter: 'mode', + source: 'Socket/communities.ts:communityJoinApprovalMode:mode', + call: (s, v) => s.communityJoinApprovalMode(GROUP, off(v)) + }, + { + method: 'downloadMedia', + parameter: 'type', + source: 'Socket/index.ts:downloadMedia:type', + call: (s, v) => + s.downloadMedia( + off({ + url: 'https://example.invalid/x', + mediaKey: new Uint8Array(32), + directPath: '/x', + mimetype: 'image/jpeg' + }), + off<'buffer' | 'stream'>(v) + ) + } +] + +/** + * Values a JavaScript caller can actually put in a closed-domain parameter. + * + * The near-misses matter most: a valid value with different casing or a trailing + * space is the mistake people really make, and it must be rejected as loudly as + * an emoji. + */ +const generateOffDomainValue = (random: Random): unknown => + random.weighted([ + [4, random.pick(['☠️', 'ADD', 'Add', 'add ', ' add', 'add\n', 'remove;', 'aDd', 'true', 'null', 'undefined', '0'])], + [3, generateString(random)], + [2, generateNumber(random)], + // `undefined` is deliberately absent: it means the argument was omitted, so + // for a defaulted or optional parameter it is not an off-domain *value* at + // all. Optionality is closed-domain-arguments.test.ts's subject. + [2, random.pick([null, true, false])], + [1, {}], + [1, []], + [1, () => undefined], + [1, Symbol('off-domain')], + [1, 'x'.repeat(4_096)], + [1, Object.create(null)], + [ + 1, + new Proxy( + {}, + { + get: () => { + throw new Error('hostile getter') + } + } + ) + ] + ]) + +interface BoundaryFinding { + readonly ok: boolean + readonly detail?: string + readonly observed?: unknown +} + +/** Everything the rejection contract requires, checked in one place. */ +/** + * This file's own name, as it appears in a V8 stack frame. + * + * Derived rather than written down: a rename would otherwise turn the + * caller-frame check into one that can never fail. + */ +const CALLER_FILE = import.meta.filename.split('/').at(-1)! + +const inspectRejection = (error: unknown, testCase: BoundaryCase): BoundaryFinding => { + if (!(error instanceof Error)) { + return { ok: false, detail: 'the rejection is not an Error', observed: String(error) } + } + + const boom = error as Error & { isBoom?: boolean; output?: { statusCode?: number }; data?: Record } + + // The stack is the whole point: a wasm-only stack is the production bug this + // boundary was built to prevent, and it is invisible to a type checker. + const stack = String(error.stack ?? '') + if (stack.includes('wasm://')) { + return { + ok: false, + detail: 'the rejection stack contains wasm frames', + observed: stack.split('\n').slice(0, 4).join(' | ') + } + } + + if (boom.isBoom !== true) { + return { + ok: false, + detail: 'the rejection is not a Boom', + observed: `${error.name}: ${error.message.slice(0, 120)}` + } + } + if (boom.output?.statusCode !== 400) { + return { ok: false, detail: 'the rejection does not carry statusCode 400', observed: boom.output?.statusCode } + } + if (boom.data?.parameter !== testCase.parameter) { + return { ok: false, detail: 'the rejection names the wrong parameter', observed: boom.data?.parameter } + } + if (!Array.isArray(boom.data?.accepted) || boom.data.accepted.length === 0) { + return { ok: false, detail: 'the rejection does not list the accepted values', observed: boom.data?.accepted } + } + if (!error.message.startsWith(`${testCase.method}: `)) { + return { + ok: false, + detail: 'the message does not open with the method the consumer called', + observed: error.message.slice(0, 120) + } + } + // The caller's own frame, which is the point of guarding at the boundary at + // all. A guard that rejects only after an internal `await` produces a stack + // rooted in the socket's internals: a fire-and-forget caller — + // `sock.sendReceipt(...)` with no await — then gets an unhandled rejection + // with nothing pointing at the line that made the call. That is invisible to a + // type checker and it is exactly what this boundary exists to prevent. + // + // Only meaningful because the call above is not awaited at its call site; + // awaiting re-adds this frame whether the guard earned it or not. + if (!stack.includes(CALLER_FILE)) { + return { + ok: false, + detail: 'the rejection stack has lost the caller frame', + observed: stack.split('\n').slice(0, 4).join(' | ') + } + } + return { ok: true } +} + +/** + * What each guarded parameter is supposed to accept, written down here. + * + * Written down, not read from the guard. The domains used to come only from the + * guard's own rejection — `data.accepted` — which made the oracle circular: a + * guard that accidentally widened its list to include `'add '` would report that + * wider list, the fuzzer would treat `'add '` as valid and skip it, and the + * off-domain acceptance this target exists to catch became unobservable. + * + * The reason for reading it at runtime was real, though, and is kept: a + * hand-copied table drifts, and a drifted table makes the fuzzer call a *valid* + * value off-domain and report the resulting "not connected" failure as a bug. + * So both exist and are compared — `pins every guarded domain` below fails if + * they disagree, naming the parameter. A widening is then a test failure rather + * than a silent loss of coverage, and drift is a test failure rather than a + * false positive. + */ +const EXPECTED_DOMAINS: Readonly> = { + 'Socket/blocking.ts:updateBlockStatus:action': ['block', 'unblock'], + 'Socket/index.ts:sendPresenceUpdate:type': ['unavailable', 'available', 'composing', 'recording', 'paused'], + 'Socket/index.ts:waUploadToServer:mediaType': [ + 'audio', + 'document', + 'gif', + 'image', + 'ppic', + 'product', + 'ptt', + 'sticker', + 'video', + 'thumbnail-document', + 'thumbnail-image', + 'thumbnail-video', + 'thumbnail-link', + 'md-msg-hist', + 'md-app-state', + 'product-catalog-image', + 'payment-bg-image', + 'ptv', + 'biz-cover-photo' + ], + 'Socket/index.ts:downloadMedia:type': ['buffer', 'stream'], + 'Socket/groups.ts:groupSettingUpdate:setting': ['announcement', 'not_announcement', 'locked', 'unlocked'], + 'Socket/groups.ts:groupRequestParticipantsUpdate:action': ['approve', 'reject'], + 'Socket/groups.ts:groupParticipantsUpdate:action': ['add', 'remove', 'promote', 'demote', 'modify'], + 'Socket/groups.ts:groupMemberAddMode:mode': ['admin_add', 'all_member_add'], + 'Socket/groups.ts:groupJoinApprovalMode:mode': ['on', 'off'], + 'Socket/messages.ts:sendReceipt:type': [ + 'read', + 'read-self', + 'hist_sync', + 'peer_msg', + 'sender', + 'inactive', + 'played', + // `undefined`, not `null`: omitting the type is how a caller sends a plain + // delivery receipt, and the two are different values to `includes`. + undefined + ], + 'Socket/messages.ts:sendReceipts:type': [ + 'read', + 'read-self', + 'hist_sync', + 'peer_msg', + 'sender', + 'inactive', + 'played', + // `undefined`, not `null`: omitting the type is how a caller sends a plain + // delivery receipt, and the two are different values to `includes`. + undefined + ], + 'Socket/newsletter.ts:newsletterMetadata:type': ['invite', 'jid'], + 'Socket/server-queries.ts:cleanDirtyBits:type': ['account_sync', 'groups'], + 'Socket/presence.ts:sendPresence:status': ['unavailable', 'available'], + 'Socket/presence.ts:sendChatState:state': ['composing', 'recording', 'paused'], + 'Socket/privacy.ts:updateLastSeenPrivacy:value': ['all', 'contacts', 'contact_blacklist', 'none'], + 'Socket/privacy.ts:updateOnlinePrivacy:value': ['all', 'match_last_seen'], + 'Socket/privacy.ts:updateProfilePicturePrivacy:value': ['all', 'contacts', 'contact_blacklist', 'none'], + 'Socket/privacy.ts:updateStatusPrivacy:value': ['all', 'contacts', 'contact_blacklist', 'none'], + 'Socket/privacy.ts:updateReadReceiptsPrivacy:value': ['all', 'none'], + 'Socket/privacy.ts:updateGroupsAddPrivacy:value': ['all', 'contacts', 'contact_blacklist'], + 'Socket/privacy.ts:updateCallPrivacy:value': ['all', 'known'], + 'Socket/privacy.ts:updateMessagesPrivacy:value': ['all', 'contacts'], + 'Socket/contacts.ts:profilePictureUrl:type': ['preview', 'image'], + 'Socket/communities.ts:communityRequestParticipantsUpdate:action': ['approve', 'reject'], + 'Socket/communities.ts:communityParticipantsUpdate:action': ['add', 'remove', 'promote', 'demote', 'modify'], + 'Socket/communities.ts:communitySettingUpdate:setting': ['announcement', 'not_announcement', 'locked', 'unlocked'], + 'Socket/communities.ts:communityMemberAddMode:mode': ['admin_add', 'all_member_add'], + 'Socket/communities.ts:communityJoinApprovalMode:mode': ['on', 'off'] +} + +/** The same values as reported by the guard itself, for the pin below. */ +const acceptedValues = new Map() + +const learnDomain = async (socket: Socket, testCase: BoundaryCase): Promise => { + try { + await testCase.call(socket, '\u2620\uFE0F-definitely-not-a-member') + } catch (error) { + const accepted = (error as { data?: { accepted?: unknown } })?.data?.accepted + if (Array.isArray(accepted)) acceptedValues.set(testCase.source, accepted) + } +} + +describe('closed-domain argument boundary, fuzzed', () => { + let socket: Socket + let folder: string + + before(async () => { + folder = await mkdtemp(path.join(tmpdir(), 'baileyrs-fuzz-domains-')) + const { state } = await useMultiFileAuthState(folder) + // Self-referencing rather than closing over `socket`: `makeWASocket` calls + // `logger.child(...)` during construction, before the assignment below has + // happened, so a closure would dereference `undefined`. + const silentLogger: Record = { + level: 'silent', + trace: () => {}, + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {} + } + silentLogger.child = () => silentLogger + + socket = makeWASocket({ + auth: state, + logger: silentLogger as never, + // Nothing listens here, so a value that survives validation fails as + // "not connected" instead of reaching a server. + waWebSocketUrl: 'ws://127.0.0.1:1' + }) + for (const testCase of CASES) await learnDomain(socket, testCase) + }) + + after(async () => { + try { + // Awaited: teardown flushes the auth stores asynchronously, and removing + // the folder underneath an in-flight write recreates it or throws after + // the test has already reported success. + await socket.end(undefined) + } catch { + // The socket never connected; ending it is best-effort cleanup. + } + await rm(folder, { recursive: true, force: true }) + }) + + it('guards every assertArgumentDomain call site in the source', async () => { + // Auto-discovery, so a new guarded parameter cannot be added without also + // being fuzzed — the same ledger discipline as the pure-helper coverage test. + // The whole of `src`, recursively, and all three quote styles: the claim in + // this file's header is "every call site in src/", and a scan of two + // directories' immediate children would let a guard in a new subdirectory + // pass without a fuzz case — the exact gap this test exists to close. + // Keyed on the path relative to `src`, not the basename: the scan is + // recursive, and two guards in different directories sharing a file name and + // the same method and parameter would otherwise collapse into one entry — + // letting a single fuzz case satisfy both and a guard ship unfuzzed. + const sourceRoot = path.join(import.meta.dirname, '..') + const scanned: string[] = [] + for (const entry of await readdir(sourceRoot, { withFileTypes: true, recursive: true })) { + if (!entry.isFile() || !entry.name.endsWith('.ts')) continue + if (entry.parentPath.includes('__fuzz__') || entry.parentPath.includes('__tests__')) continue + const file = path.join(entry.parentPath, entry.name) + const relative = path.relative(sourceRoot, file).split(path.sep).join('/') + const source = await readFile(file, 'utf8') + for (const match of source.matchAll(/assertArgumentDomain\(\s*['"`]([^'"`]+)['"`],\s*['"`]([^'"`]+)['"`]/gu)) { + scanned.push(`${relative}:${match[1]}:${match[2]}`) + } + } + + assert.ok( + scanned.length > 20, + `the source scan found only ${scanned.length} guarded parameters — has the pattern changed?` + ) + + const covered = new Set(CASES.map(testCase => testCase.source)) + // downloadMediaMessage is a standalone helper rather than a socket method; + // closed-domain-arguments.test.ts drives it directly. + const exempt = new Set(['Utils/messages.ts:downloadMediaMessage:type']) + const missing = scanned.filter(entry => !covered.has(entry) && !exempt.has(entry)) + assert.deepEqual(missing, [], `guarded parameters with no fuzz case: ${missing.join(', ')}`) + + const stale = [...covered].filter(entry => !scanned.includes(entry)) + assert.deepEqual(stale, [], `fuzz cases for guards that no longer exist: ${stale.join(', ')}`) + }) + + it('pins every guarded domain to a written-down list', () => { + // The two halves of the oracle, compared. `EXPECTED_DOMAINS` is what the + // fuzzer treats as valid; `acceptedValues` is what the guard reports at + // runtime. Equal, or one of them is wrong — and which one is a question for + // whoever changed it, which is why this fails rather than reconciling. + // + // Order-insensitive, since the guard may list its domain in any order, but + // membership-exact: a value in one and not the other is the whole point. + const mismatched: string[] = [] + const unreported: string[] = [] + for (const testCase of CASES) { + const expected = EXPECTED_DOMAINS[testCase.source] + if (expected === undefined) { + mismatched.push(`${testCase.source}: no entry in EXPECTED_DOMAINS`) + continue + } + const reported = acceptedValues.get(testCase.source) + if (reported === undefined) { + // The guard never reported a domain — it may not have been reached, or + // it rejected without `data.accepted`. Surfaced separately, because it + // means the pin proved nothing for that parameter rather than failing. + unreported.push(testCase.source) + continue + } + // Tagged with the type, not stringified. `String(undefined)` is the same + // six characters as the literal string `'undefined'`, and three of these + // domains end in an optional `undefined` member — so a guard that started + // accepting the *string* instead of the absent value matched this pin + // exactly. Only the randomised off-domain target could have caught it, and + // only if a seed happened to draw that one string. + const key = (values: readonly unknown[]) => + JSON.stringify([...values].map(item => `${typeof item}:${String(item)}`).toSorted()) + if (key(expected) !== key(reported)) { + mismatched.push( + `${testCase.source}: guard accepts ${JSON.stringify(reported)}, table says ${JSON.stringify(expected)}` + ) + } + } + assert.deepEqual(mismatched, [], `guarded domains disagree with the table:\n ${mismatched.join('\n ')}`) + assert.deepEqual( + unreported, + [], + `these guards never reported an accepted list, so their domain is unpinned:\n ${unreported.join('\n ')}` + ) + }) + + for (const testCase of CASES) { + it(`${testCase.method}(${testCase.parameter})`, async () => { + await fuzz({ + target: `args:${testCase.method}`, + runs: 60, + shrinkFailures: false, + generate: generateOffDomainValue, + check: async value => { + const findings: Divergence[] = [] + // A generated value that happens to be in the domain (`undefined` for + // an optional or defaulted parameter) is not off-domain, and what it + // does downstream is not this test's subject. + // + // Read from `EXPECTED_DOMAINS`, never from the guard's own report: a + // guard that widened its accepted list would otherwise vouch for the + // very value that proves it widened. + if ((EXPECTED_DOMAINS[testCase.source] ?? []).includes(value)) return findings + // Invoked without `await` at the call site, and the outcome taken from + // handlers attached to the returned promise. + // + // `await socket.method(...)` lets V8 splice the awaiting frame into the + // rejection's stack, so a guard that had moved past an internal await — + // losing the caller's frame for every real fire-and-forget caller — + // still produced a stack naming this file, and the checks below passed + // on a trace the guard had not actually produced. Attaching handlers + // instead means what is inspected is the stack as it was thrown. + const settled = await new Promise<{ readonly ok: boolean; readonly error?: unknown }>(resolve => { + let pending: unknown + try { + pending = testCase.call(socket, value) + } catch (error) { + // A guard that rejects synchronously, before returning a promise. + resolve({ ok: false, error }) + return + } + Promise.resolve(pending).then( + () => resolve({ ok: true }), + (error: unknown) => resolve({ ok: false, error }) + ) + }) + + if (settled.ok) { + // Reaching here means the value was accepted. It cannot have been + // valid — everything generated is off-domain — so the guard let it + // through and it is on its way to the bridge. + findings.push({ + target: `args:${testCase.method}`, + input: value, + local: '', + upstream: '', + detail: 'an off-domain value passed the guard' + }) + return findings + } + + const verdict = inspectRejection(settled.error, testCase) + if (!verdict.ok) { + findings.push({ + target: `args:${testCase.method}`, + input: value, + local: verdict.observed, + upstream: '', + detail: verdict.detail + }) + } + return findings + } + }) + }) + } +}) diff --git a/src/__fuzz__/bridge-events.fuzz.test.ts b/src/__fuzz__/bridge-events.fuzz.test.ts new file mode 100644 index 00000000..cb86f738 --- /dev/null +++ b/src/__fuzz__/bridge-events.fuzz.test.ts @@ -0,0 +1,987 @@ +/** + * Bridge anti-corruption layer and event buffer, fuzzed on sequences. + * + * `adaptBridgeEvent` is the only thing standing between a WASM runtime and every + * consumer's `sock.ev.on(...)` handler. Its contract is that a shape it does not + * recognise is *dropped* — returns null — and never thrown, because a throw here + * takes down the socket's event loop rather than one event. That contract is + * exactly the kind that unit tests confirm for the shapes somebody imagined. + * + * The event buffer is the other half: it consolidates events while a history sync + * is in flight, and a bug there is silent by construction — a lost `messages.upsert` + * looks like a message that never arrived. It is fuzzed differentially against + * upstream's `makeEventBuffer` on the same emit/buffer/flush sequences, plus the + * invariants that hold regardless of what upstream does. + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { adaptBridgeEvent, adaptBridgeMessageWire, KNOWN_BRIDGE_EVENT_TYPES } from '../Bridge/adapt.ts' +import { makeEventBuffer } from '../Utils/event-buffer.ts' +import type { ILogger } from '../Utils/logger.ts' +import { compareOutcomes, equivalent, normalise, runOutcome, showOutcome } from './harness/compare.ts' +import type { Divergence } from './harness/divergence.ts' +import { fuzz } from './harness/runner.ts' +import type { Random } from './harness/random.ts' +import { + BRIDGE_EVENT_TYPES, + generateBridgeEvent, + generateBridgeEventSequence, + generateMessageWire, + shapedBridgePayload, + type BridgeEventCase +} from './generators/bridge-event.ts' +import { generateJid } from './generators/jid.ts' +import { generateNumber, generateString } from './generators/values.ts' + +const upstream = (await import('baileys')) as unknown as { + makeEventBuffer: (logger: unknown) => UpstreamBuffer + /** The oracle for `isGroup`, so the envelope check does not restate ours. */ + isJidGroup: (jid: string) => boolean | undefined +} + +interface UpstreamBuffer { + on(event: string, handler: (data: unknown) => void): void + emit(event: string, data: unknown): boolean + buffer(): void + flush(): boolean + process(handler: (events: Record) => void): () => void +} + +const silentLogger = { + level: 'silent', + child: () => silentLogger, + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined +} as unknown as ILogger + +/** + * The canonical tag a bridge event type is expected to adapt to. + * + * A convention plus its exceptions, not a transcription of the adapter's own + * dispatch: the convention is snake_case to camelCase, which holds for 47 of the + * 58 declared types, and the eleven below are the real renames and merges — each + * one a decision somebody made rather than a mechanical transformation. Changing + * any of them has to be a deliberate edit here, which is the point. + * + * The last four were missed on the first pass because the fixed seed never drew + * a payload that cleared their guards: all four returned `noop` on every run and + * only a deep seed reached their success path. A sampled expectation table is + * not a complete one — these were filled in from the adapter's declared returns + * once the deep run proved sampling insufficient. + */ +const TAG_EXCEPTIONS: Readonly> = { + // Three different bridge signals, one canonical call event. + incoming_call: 'incomingCall', + missed_call: 'incomingCall', + call_ended_elsewhere: 'incomingCall', + // A pairing code is shown to the user exactly as a QR is. + pairing_code: 'qr', + // Past tense on the wire, present tense in the canonical event. + contact_updated: 'contactUpdate', + // The `_update` suffix is dropped on the two label events. + label_edit_update: 'labelEdit', + label_association_update: 'labelAssociation', + // Named after what the event *is* rather than after the wire signal that + // carries it — verb first, and the `_update` suffix dropped again. + delete_chat_update: 'chatDelete', + clear_chat_update: 'chatClear', + delete_message_for_me_update: 'messageDelete', + // A changed contact number is how the runtime tells us about a LID mapping. + contact_number_changed: 'lidMappingUpdate' +} + +/** + * The `edit` attribute values that survive into `editAttribute`. + * + * Written down rather than read from the adapter, which is the point: this is a + * protocol constant, and a new value silently starting or stopping to pass + * through should be a deliberate edit here. `generateMessageWire` also draws + * `''` and `'x'`, which must come back as undefined. + */ +const EDIT_ATTRIBUTES: ReadonlySet = new Set(['1', '2', '3', '7', '8']) + +const camelCase = (value: string): string => + value.replaceAll(/_([a-z])/gu, (_match, letter: string) => letter.toUpperCase()) + +/** + * The types whose adapter can only ever return `{ type: 'noop' }`. + * + * Derived from the adapter's declared returns, not from what a run happened to + * produce: an entry whose body contains no `type:` literal other than `'noop'` + * cannot surface anything, whatever payload it is given. Sampling got this + * wrong once already — four types that only no-op'd on the fixed seed turned + * out to have reachable success paths on a deep one — so this is read from the + * source and then checked against eight seeds, where no member ever surfaced a + * real event. + */ +const UNCONDITIONALLY_INERT: ReadonlySet = new Set([ + 'pairing_code_refresh', + 'pair_passkey_request', + 'pair_passkey_confirmation', + 'pair_passkey_error', + 'self_push_name_updated', + 'offline_sync_preview', + 'device_list_update', + 'identity_change', + 'business_status_update', + 'contact_sync_requested', + 'user_about_update', + 'user_status_mute_update' +]) + +/** + * Types that *can* surface an event but whose guard this file does not reliably + * clear. + * + * A gap here, not a decision in the adapter — which is why they are listed apart + * from the set above rather than folded into it. Each needs a shaped payload + * that satisfies its guard; until then `noop` has to be tolerated for them, or + * the sweep fails on this file's shortcoming rather than on the library's + * behaviour. + * + * The membership rule is "no-ops on at least one seed", not "no-ops on the + * fixed seed". Four of these do surface on some seeds and not others, so a set + * built from one run would have passed there and failed on the nightly's next + * seed. Measured as the union across ten seeds, each run exactly as the target + * runs it — the same cursor over the type list, the same 16 rounds. + */ +const NOT_YET_REACHABLE: ReadonlySet = new Set([ + 'clear_chat_update', + 'contact_number_changed', + 'delete_chat_update', + 'delete_message_for_me_update', + 'disappearing_mode_changed', + 'label_association_update', + 'label_edit_update', + 'newsletter_live_update' +]) + +/** + * `noop` used to be allowed for every declared type, on the reasoning that the + * collapse assertion after the sweep would catch an adapter that stopped + * surfacing things. It does not: measured, one production event regressing to + * `noop` takes the distinct-tag count from 34 to 33 and three take it to 31, + * both far above the floor of 25. Losing a handful of events stayed green. + * + * So `noop` is now allowed only where it is the adapter's whole behaviour, or + * where this file cannot yet reach the success path. A type outside both sets + * that returns `noop` is an event that stopped being surfaced, and that should + * be a deliberate edit here rather than a silent change. + */ +const allowedTags = (type: string): ReadonlySet => { + const canonical = TAG_EXCEPTIONS[type] ?? camelCase(type) + return UNCONDITIONALLY_INERT.has(type) || NOT_YET_REACHABLE.has(type) + ? new Set([canonical, 'noop']) + : new Set([canonical]) +} + +// --------------------------------------------------------------------------- +// The anti-corruption layer +// --------------------------------------------------------------------------- + +describe('bridge event adaptation', () => { + it('never throws, whatever the runtime sends', async () => { + await fuzz({ + target: 'bridge:adapt-total', + runs: 500, + generate: random => generateBridgeEventSequence(random), + check: sequence => { + if (!Array.isArray(sequence)) return [] + const findings: Divergence[] = [] + for (const event of sequence) { + let result: unknown + try { + result = adaptBridgeEvent(event as never, silentLogger) + } catch (error) { + findings.push({ + target: 'bridge:adapt-total', + input: event, + local: ``, + upstream: '', + detail: 'a malformed bridge event threw instead of being dropped' + }) + continue + } + // `null` means "drop it"; anything else must be a tagged canonical + // event, because `Socket/events.ts` switches on that tag. + if (result === null) continue + if (typeof result !== 'object' || typeof (result as { type?: unknown }).type !== 'string') { + findings.push({ + target: 'bridge:adapt-total', + input: event, + local: result, + upstream: '', + detail: 'the adapter returned something the event dispatcher cannot switch on' + }) + } + } + return findings + } + }) + }) + + it('drops every event type it does not declare', async () => { + await fuzz({ + target: 'bridge:adapt-unknown', + runs: 400, + generate: random => ({ + type: random.weighted([ + [4, generateString(random)], + [2, `${random.pick(BRIDGE_EVENT_TYPES)}_v2`], + [1, '__proto__'], + [1, 'constructor'], + [1, 'hasOwnProperty'] + ]), + data: generateBridgeEvent(random).data + }), + check: event => { + if (typeof event?.type !== 'string') return [] + if (KNOWN_BRIDGE_EVENT_TYPES.has(event.type)) return [] + + let result: unknown + try { + result = adaptBridgeEvent(event as never, silentLogger) + } catch (error) { + return { + target: 'bridge:adapt-unknown', + input: event, + local: ``, + upstream: 'null', + detail: 'an unknown event type threw instead of being dropped' + } + } + if (result === null) return [] + // `__proto__`, `constructor` and friends resolve on a plain object even + // when nobody put them there — a lookup table indexed by an untrusted + // string has to be immune to that. + return { + target: 'bridge:adapt-unknown', + input: event, + local: result, + upstream: 'null', + detail: 'an event type the table does not declare produced a canonical event' + } + } + }) + }) + + it('adapts deterministically', async () => { + await fuzz({ + target: 'bridge:adapt-deterministic', + runs: 400, + generate: generateBridgeEvent, + check: event => { + // Throwing twice the same way is deterministic; that the throw happens at + // all is bridge:adapt-total's subject, not this one's. + const once = runOutcome(() => adaptBridgeEvent(structuredClone(event) as never, silentLogger)) + const twice = runOutcome(() => adaptBridgeEvent(structuredClone(event) as never, silentLogger)) + // Strict, unlike every cross-implementation comparison in this suite. + // The tolerances exist to bridge two runtimes: `coerceScalars` folds a + // Rust u64 and a protobufjs Long together, and dropping absent keys + // papers over two libraries spelling "not set" differently. Neither + // applies to one implementation compared against itself — and both + // erase exactly what this target is looking for. An adapter carrying + // state that made consecutive calls alternate between `'0'` and `0`, or + // between an absent property and a present `undefined` one, is + // observable to every caller and was being called deterministic. + if (compareOutcomes(once, twice, { coerceScalars: false, preservePresence: true }).same) return [] + return { + target: 'bridge:adapt-deterministic', + input: event, + local: showOutcome(once), + upstream: showOutcome(twice), + detail: 'the same event adapted to two different results — the layer is carrying state' + } + } + }) + }) + + it('adapts every declared event type to the canonical event it belongs to', async () => { + // Finite and exhaustive: the table declares these, so all of them are checked. + let cursor = 0 + // Not throwing is a low bar: an adapter that returns `null` for every input + // it is ever shown clears it, and so does one whose guard clause rejects a + // payload the generator can never satisfy. Measured before this counter + // existed, 22 of the 58 declared types adapted to `null` on every run — a + // third of the table was declared covered while none of its mapping ran. + // So the return value is recorded per type and asserted after the sweep. + // + // And counting a non-null result was itself too weak: an adapter that routed + // `push_name_update` to a generic `{ type: 'message' }` incremented the same + // counter, and neither the totality target (any object with a string tag) nor + // the determinism target (the same wrong answer twice) could see it. So the + // tag is checked against the one this type belongs to, and every tag observed + // per type is recorded for the collapse checks below. + const adapted = new Map(BRIDGE_EVENT_TYPES.map(type => [type, 0])) + const tagsSeen = new Map>(BRIDGE_EVENT_TYPES.map(type => [type, new Set()])) + const report = await fuzz<{ type: string; data: unknown }>({ + target: 'bridge:adapt-coverage', + // Sixteen tries per type, not eight: the shaped payload still fuzzes the + // fields inside the shape, so a type gated on three of them at once needs + // the headroom to clear the guard at least once on every seed. + runs: BRIDGE_EVENT_TYPES.length * 16, + exhaustive: true, + shrinkFailures: false, + generate: (random: Random) => { + const type = BRIDGE_EVENT_TYPES[cursor++ % BRIDGE_EVENT_TYPES.length]! + return { type, data: shapedBridgePayload(random, type) } + }, + check: event => { + let canonical: { type?: unknown } | null + try { + canonical = adaptBridgeEvent(event as never, silentLogger) as { type?: unknown } | null + } catch (error) { + return { + target: 'bridge:adapt-coverage', + input: event, + local: ``, + upstream: '', + detail: 'a declared event type threw on a fuzzed payload' + } + } + if (canonical == null) return [] + adapted.set(event.type, (adapted.get(event.type) ?? 0) + 1) + const tag = String(canonical.type) + tagsSeen.get(event.type)?.add(tag) + if (allowedTags(event.type).has(tag)) return [] + return { + target: 'bridge:adapt-coverage', + input: event, + local: tag, + upstream: [...allowedTags(event.type)].join(' | '), + detail: 'a declared event type adapted to a canonical event it does not belong to' + } + } + }) + + // Not when the target was filtered out. `FUZZ_ONLY=proto:` skips this sweep + // entirely, and the counter would then report every declared type as inert — + // a failure about a run that never happened. + if (report.runs === 0) return + + // Asserted here rather than reported as a divergence: these are statements + // about the *generator* and about the table as a whole, not about a + // disagreement between two libraries, and no allowlist entry should be able + // to excuse them. + const inert = [...adapted].filter(([, count]) => count === 0).map(([type]) => type) + assert.deepEqual( + inert, + [], + `these declared event types adapted to null on every run — the generator never produces a payload their adapter accepts, so their mapping is untested:\n ${inert.join('\n ')}` + ) + + // One non-noop tag per type. The per-event check above allows `noop` + // everywhere, because most of these types are deliberately inert; this stops + // that allowance from hiding a tag that varies with the payload, which would + // mean the routing depends on the data rather than on the event type. + const unstable = [...tagsSeen] + .map(([type, tags]) => [type, [...tags].filter(tag => tag !== 'noop')] as const) + .filter(([, tags]) => tags.length > 1) + assert.deepEqual( + unstable.map(([type]) => type), + [], + `these types produced more than one canonical tag, so their routing depends on the payload rather than the type:\n ${unstable.map(([type, tags]) => `${type} -> ${tags.join(' | ')}`).join('\n ')}` + ) + + // And the table must not collapse. Since `noop` is allowed for every type, an + // adapter that no-op'd everything would satisfy both checks above while + // testing nothing. Measured at 34 distinct non-noop tags on the fixed seed; + // the floor is well under that so an unlucky draw cannot trip it, but well + // over what a collapse would leave. + const distinct = new Set([...tagsSeen.values()].flatMap(tags => [...tags]).filter(tag => tag !== 'noop')) + assert.ok( + distinct.size >= 25, + `only ${distinct.size} distinct canonical tags across the whole table — the adapter is collapsing event types onto a shared result` + ) + }) + + it('carries the message and its envelope through the message-wire adapter', async () => { + // The result is inspected, not discarded. "Never throws" on its own is + // cleared by an adapter that returns `null` for every input it is ever + // shown, so a regression that dropped every valid message would have passed + // all 400 cases. Nor is the envelope enough on its own: with only the tag, + // chat and id checked, an adapter that replaced `messageProto` with `{}` — + // losing the entire payload, which is the one thing this transport exists to + // carry — still passed every case. So the payload is compared too. + // + // Across the sweep the adapter also has to accept *something*: measured at + // 288 of 400 draws on the fixed seed, so a floor of one is a liveness check + // with headroom rather than a threshold that flakes on an unlucky seed. + let accepted = 0 + const report = await fuzz>({ + target: 'bridge:message-wire', + runs: 400, + generate: generateMessageWire, + check: wire => { + let canonical: ReturnType + try { + canonical = adaptBridgeMessageWire(wire.message, wire.info as never, silentLogger) + } catch (error) { + return { + target: 'bridge:message-wire', + input: wire, + local: ``, + upstream: '', + detail: 'a malformed message-wire payload threw instead of being dropped' + } + } + // Dropping a payload is allowed only where the adapter says it is. + // `adaptBridgeMessageWire` returns null on exactly two conditions — a + // non-object message, or a `chat`/`id` that is not a non-empty string — + // and the generator produces plenty of those on purpose. Anything else + // is a *valid* message being dropped. + // + // Skipping every null was too generous by a whole category. The + // `accepted > 0` floor below only catches total collapse, so an adapter + // that returned null for, say, every group message went on passing all + // 400 cases on the strength of the ones it still accepted. + const info = wire.info as Record + // + // An array is not one of them: a message proto is a keyed structure, and + // `[]` carries no fields to adapt. The first version of this check said + // only `typeof === 'object'`, and the shrinker immediately produced + // `{ info: { id: '3', chat: '1' }, message: [] }` — a correct rejection + // reported as a dropped message. + const wellFormed = + typeof wire.message === 'object' && + wire.message !== null && + !Array.isArray(wire.message) && + typeof info?.chat === 'string' && + info.chat.length > 0 && + typeof info.id === 'string' && + info.id.length > 0 + if (canonical === null) { + if (!wellFormed) return [] + return { + target: 'bridge:message-wire', + input: wire, + local: 'null', + upstream: '', + detail: 'the adapter dropped a message whose chat, id and payload were all well formed' + } + } + accepted++ + + // Deep, not by reference: an adapter that copies the proto on its way + // through is doing nothing wrong, and pinning identity would forbid it. + // What must not change is the content — and `content` includes the + // runtime type. `coerceScalars` defaults on, which exists so a Rust u64 + // can be compared against a protobufjs Long; here it meant a generated + // `conversation: '0'` compared equal to an adapter that handed back + // `conversation: 0`, which a consumer can tell apart with `typeof`, + // `===` or arithmetic. Off, as the pure-helper differential has it, for + // the same reason: this is a plain JavaScript value, not a value being + // carried across two protobuf runtimes. + const strict = { preservePresence: true, coerceScalars: false } + if (!equivalent(canonical.messageProto, wire.message, strict)) { + return { + target: 'bridge:message-wire', + input: wire, + local: normalise(canonical.messageProto), + upstream: normalise(wire.message), + detail: 'the adapter accepted a message but did not carry its proto through unchanged' + } + } + + // The scalar envelope, but only where the input makes the expected value + // unambiguous. Re-deriving `asString`/`asNumber` for a malformed input + // would just restate the adapter's own coercion rules back at it, which + // proves nothing; a plain string pushName or a finite timestamp has one + // correct answer that does not depend on them. + const envelope: Record = { type: canonical.type, chatJid: canonical.chatJid, id: canonical.id } + const expected: Record = { type: 'message', chatJid: info.chat, id: info.id } + if (typeof info.pushName === 'string') { + envelope.pushName = canonical.pushName + expected.pushName = info.pushName + } + if (typeof info.timestamp === 'number' && Number.isFinite(info.timestamp)) { + envelope.timestamp = canonical.timestamp + expected.timestamp = info.timestamp + } + if (typeof info.isFromMe === 'boolean') { + envelope.isFromMe = canonical.isFromMe + expected.isFromMe = info.isFromMe + } + + // The rest of the envelope. The generator populates every one of these + // on purpose, and with only type/chat/id/pushName/timestamp/isFromMe + // compared, an adapter that dropped `isViewOnce`, mis-mapped + // `participantAlt`, or stopped routing `senderAlt` by direction passed + // all 400 cases — the alternate-JID mapping is the part most likely to + // rot, and it was the part nothing looked at. + // + // `isGroup` comes first because four of the others are conditioned on it, + // and it is derived from upstream's own `isJidGroup` rather than from the + // adapter's. That keeps it an oracle: restating `src/WABinary`'s helper + // here would compare the adapter against a copy of its own dependency. + const isGroup = info.isGroup === true || upstream.isJidGroup(String(info.chat)) === true + const senderAlt = typeof info.senderAlt === 'string' ? info.senderAlt : undefined + const recipientAlt = typeof info.recipientAlt === 'string' ? info.recipientAlt : undefined + const isFromMe = info.isFromMe === true + + envelope.isGroup = canonical.isGroup + expected.isGroup = isGroup + + // A group carries a participant; a one-to-one chat has none to carry. + envelope.senderJid = canonical.senderJid + expected.senderJid = isGroup && typeof info.sender === 'string' ? info.sender : undefined + + // The alternate-JID pair, which is the whole reason both fields exist: + // in a group the sender's alt is the *participant*, outside one it is the + // *chat*, and outgoing messages carry the recipient's alt instead. + envelope.participantAlt = canonical.participantAlt + expected.participantAlt = isGroup ? senderAlt : undefined + envelope.remoteJidAlt = canonical.remoteJidAlt + expected.remoteJidAlt = isGroup ? undefined : isFromMe ? recipientAlt : senderAlt + + // Tri-state on purpose: these are `true` or absent, never `false`, so a + // regression to `asBoolOr(..., false)` shows up here rather than passing + // as "close enough". `strict` keeps `preservePresence` on, which is what + // makes `undefined` distinguishable from `false`. + envelope.isViewOnce = canonical.isViewOnce + expected.isViewOnce = info.isViewOnce === true ? true : undefined + envelope.isOffline = canonical.isOffline + expected.isOffline = info.isOffline === true ? true : undefined + + envelope.unavailableRequestId = canonical.unavailableRequestId + expected.unavailableRequestId = + typeof info.unavailableRequestId === 'string' ? info.unavailableRequestId : undefined + + // The accepted set written down rather than re-derived: the generator + // also draws `''` and `'x'`, which have to come back as undefined. + envelope.editAttribute = canonical.editAttribute + expected.editAttribute = EDIT_ATTRIBUTES.has(info.edit as string) ? info.edit : undefined + + if (equivalent(envelope, expected, strict)) return [] + return { + target: 'bridge:message-wire', + input: wire, + local: envelope, + upstream: expected, + detail: 'the adapter accepted a message but changed its envelope' + } + } + }) + + // Asserted rather than reported, for the same reason as the coverage counter + // above: this is a statement about the generator and the adapter's liveness, + // and no allowlist entry should be able to excuse it. Skipped when FUZZ_ONLY + // filtered the target out, since the counter would then be about a run that + // never happened. + if (report.runs === 0) return + assert.ok( + accepted > 0, + `adaptBridgeMessageWire returned null on all ${report.runs} draws — either the generator stopped producing valid payloads or the adapter now drops everything` + ) + }) +}) + +// --------------------------------------------------------------------------- +// The event buffer +// --------------------------------------------------------------------------- + +/** The events the buffer consolidates, plus one it must pass straight through. */ +const EMITTABLE = [ + 'messaging-history.set', + 'chats.upsert', + 'chats.update', + 'chats.delete', + 'contacts.upsert', + 'contacts.update', + 'messages.upsert', + 'messages.update', + 'messages.delete', + 'messages.reaction', + 'message-receipt.update', + 'groups.update', + 'connection.update' +] as const + +type Step = + | { readonly kind: 'emit'; readonly event: string; readonly data: unknown } + | { readonly kind: 'buffer' } + | { readonly kind: 'flush' } + +const messageKey = (random: Random) => ({ + remoteJid: generateJid(random), + id: random.pick(['A1', 'B2', 'C3', 'D4']), + fromMe: random.bool(), + participant: random.bool(0.3) ? generateJid(random) : undefined +}) + +/** + * A four-entry jid pool for the history-set payloads. + * + * Small enough that two rows in one batch, or two batches before a flush, + * collide often — which is what drives the merge and dedup branches. The + * occasional draw from the full grammar keeps the hostile shapes reachable + * without diluting the pool into uniqueness. + */ +const HISTORY_JIDS = [ + '15551234567@s.whatsapp.net', + '15550000000@s.whatsapp.net', + '120363000000000000@g.us', + '100000000000000@lid' +] as const + +const historyJid = (random: Random): string => (random.bool(0.9) ? random.pick(HISTORY_JIDS) : generateJid(random)) + +/** + * The identities one generated sequence draws from. + * + * The same reasoning as `historyJid` one level up, applied across steps rather + * than within one payload. The buffer's whole job is consolidation — absorbing a + * pending `messages.update` into an upsert, attaching a reaction or a receipt to + * a buffered message, folding a `chats.update` into a `chats.upsert` — and it + * keys all of that on `${remoteJid},${id},${fromMe}`. With every payload drawing + * a fresh independent key, none of those branches ran: measured over the fixed + * `buffer:differential` stream, **zero** of 466 update/reaction/receipt events + * shared a key with any preceding upsert in the same sequence. + * + * So a sequence picks from a handful of identities, and mostly reuses them. Not + * always: a follow-up for a message nobody buffered is its own branch, and a + * pool of one would collapse every event onto a single key and stop testing that + * the buffer keeps distinct messages apart. + */ +interface StepPool { + readonly keys: readonly Record[] + readonly chats: readonly string[] +} + +const makeStepPool = (random: Random): StepPool => ({ + keys: Array.from({ length: random.int(2, 3) }, () => messageKey(random)), + chats: Array.from({ length: random.int(2, 3) }, () => generateJid(random)) +}) + +/** A key from the pool most of the time, a fresh one otherwise. */ +const pooledKey = (random: Random, pool: StepPool): Record => + random.bool(0.75) ? { ...random.pick([...pool.keys]) } : messageKey(random) + +const pooledChat = (random: Random, pool: StepPool): string => + random.bool(0.75) ? random.pick([...pool.chats]) : generateJid(random) + +const payloadFor = (random: Random, event: string, pool: StepPool): unknown => { + switch (event) { + // The buffer's largest stateful branch: it merges chats, contacts and + // messages into `historySets` by id, folds later chat updates into entries + // already there, and carries syncType/progress/isLatest across flushes. + // + // Ids come from `historyJid`, not `generateJid`, and that is the whole point. + // The merge only runs when the same id shows up twice, and the full grammar + // draws ~214 distinct values in 300 tries, so the fold would be reached by + // coincidence at best. Its most common single value is the empty string, + // which fails the other way round — every row collapses under the `''` key + // and the dedup looks exercised while nothing distinct was ever merged. + case 'messaging-history.set': + return { + chats: Array.from({ length: random.int(0, 3) }, () => ({ + id: pooledChat(random, pool), + conversationTimestamp: generateNumber(random), + unreadCount: random.int(0, 5), + endOfHistoryTransferType: random.bool(0.3) ? random.int(0, 2) : undefined + })), + contacts: Array.from({ length: random.int(0, 3) }, () => ({ + id: pooledChat(random, pool), + name: random.bool(0.5) ? generateString(random) : undefined, + notify: random.bool(0.3) ? generateString(random) : undefined + })), + // From the sequence pool, like every other row. An independent + // `messageKey` here meant a buffered history message and a later live + // `messages.upsert`/`messages.update` shared a key only by accident, and + // the buffer's two explicit history-to-live branches + // (`src/Utils/event-buffer.ts:244` and `:264`, where a history message is + // *replaced* by the live one rather than accumulated as a separate + // upsert) never ran at all — instrumented over the fixed stream, 0 hits + // on each. A regression that stopped applying live traffic to buffered + // history stayed green. + messages: Array.from({ length: random.int(0, 3) }, () => ({ + key: pooledKey(random, pool), + messageTimestamp: generateNumber(random), + message: { conversation: generateString(random) } + })), + pastParticipants: random.bool(0.25) + ? [{ groupJid: historyJid(random), pastParticipants: [{ userJid: historyJid(random), leaveReason: 0 }] }] + : undefined, + syncType: random.bool(0.5) ? random.int(0, 5) : undefined, + progress: random.bool(0.5) ? random.int(0, 100) : undefined, + chunkOrder: random.int(0, 3), + isLatest: random.bool(), + peerDataRequestSessionId: random.bool(0.3) ? generateString(random) : undefined + } + case 'chats.upsert': + return [ + { id: pooledChat(random, pool), conversationTimestamp: generateNumber(random), unreadCount: random.int(0, 5) } + ] + case 'chats.update': + return [{ id: pooledChat(random, pool), unreadCount: random.int(0, 5), name: generateString(random) }] + case 'chats.delete': + return [pooledChat(random, pool)] + case 'contacts.upsert': + return [{ id: pooledChat(random, pool), name: generateString(random) }] + case 'contacts.update': + return [{ id: pooledChat(random, pool), name: generateString(random) }] + case 'messages.upsert': + return { + type: random.pick(['notify', 'append']), + messages: [ + { + key: pooledKey(random, pool), + messageTimestamp: generateNumber(random), + message: { conversation: generateString(random) } + } + ] + } + case 'messages.update': + return [{ key: pooledKey(random, pool), update: { status: random.int(0, 4) } }] + case 'messages.delete': + return random.bool(0.5) ? { keys: [pooledKey(random, pool)] } : { jid: pooledChat(random, pool), all: true } + case 'messages.reaction': + return [ + { + key: pooledKey(random, pool), + reaction: { text: random.pick(['👍', '❤️', '']), key: pooledKey(random, pool) } + } + ] + case 'message-receipt.update': + return [ + { + key: pooledKey(random, pool), + receipt: { userJid: pooledChat(random, pool), readTimestamp: generateNumber(random) } + } + ] + case 'groups.update': + return [{ id: pooledChat(random, pool), subject: generateString(random) }] + default: + return { connection: random.pick(['open', 'close', 'connecting']) } + } +} + +const generateSteps = (random: Random): Step[] => { + const steps: Step[] = [] + // One pool per sequence, so the events within it can refer to the same + // messages and chats — which is the only way the buffer's consolidation + // branches are reached at all. + const pool = makeStepPool(random) + // Drawn once: in the loop condition the bound would be re-rolled every pass. + const stepCount = random.int(2, 20) + for (let index = 0; index < stepCount; index++) { + steps.push( + random.weighted([ + [ + 6, + (() => { + const event = random.pick(EMITTABLE) + return { kind: 'emit', event, data: payloadFor(random, event, pool) } as Step + })() + ], + [2, { kind: 'buffer' } as Step], + [2, { kind: 'flush' } as Step] + ]) + ) + } + return steps +} + +/** + * Runs a step list through an emitter and records everything observable: the + * events it released, in order, and any step that threw. + * + * Throws are part of the observation rather than an error in the fuzzer, because + * "baileyrs throws on a payload upstream absorbs" is precisely the kind of + * difference worth reporting — and because shrinking will happily hand both + * buffers a payload neither was built for, where agreeing to throw is the + * correct answer. + */ +type Observation = + | { readonly released: string; readonly data: unknown } + | { readonly threw: string; readonly at: string } + // `flush()` answers "was there anything to release", and callers branch on it. + // Discarding it meant a buffer that returned the wrong boolean while releasing + // the right events agreed with upstream on both targets — and the fixed + // invariant below only covers one `chats.upsert` sequence, so any other + // consolidation path returning `true` after it had already drained was + // invisible. + | { readonly flushed: boolean } + // And the drain's cap is an observation too: without it, a buffer that + // answered `true` forever left the loop at 100 with nothing recorded, which + // reads exactly like one that drained cleanly. + | { readonly drainedAfter: number; readonly exhausted: boolean } + +const observe = ( + make: () => { + on(event: string, handler: (data: unknown) => void): void + emit(event: string, data: unknown): boolean + buffer(): void + flush(): boolean + }, + steps: readonly Step[] +): Observation[] => { + const emitter = make() + const seen: Observation[] = [] + for (const event of EMITTABLE) emitter.on(event, data => seen.push({ released: event, data })) + + const guard = (label: string, work: () => void) => { + try { + work() + } catch (error) { + seen.push({ threw: (error as Error)?.name ?? 'Error', at: label }) + } + } + + for (const step of steps) { + if (step.kind === 'emit') guard(`emit ${step.event}`, () => emitter.emit(step.event, structuredClone(step.data))) + else if (step.kind === 'buffer') guard('buffer', () => emitter.buffer()) + else { + // Recorded in the stream, in order, so a wrong answer shows up as a + // divergence at the step that produced it rather than not at all. + guard('flush', () => seen.push({ flushed: emitter.flush() })) + } + } + + // Drain whatever is still buffered, so a sequence that ends mid-buffer is + // compared on what it holds rather than on what it happened to have released. + const cap = 100 + let drained = 0 + let exhausted = false + for (; drained < cap; drained++) { + let more = false + guard('drain', () => { + more = emitter.flush() + }) + if (!more) { + exhausted = true + break + } + } + seen.push({ drainedAfter: drained, exhausted }) + + // Each buffer registers listeners, a history cache and up to two timers. The + // differential and conservation targets build two per case across 300 cases + // each, so releasing them keeps peak memory honest. `destroy` is not on the + // upstream interface, hence the guarded call. + ;(emitter as { destroy?: () => void }).destroy?.() + return seen +} + +/** + * Shrinking drops object keys, so it will propose an `emit` step with no event + * name. That is not a smaller version of the failure — it is a different input + * the property was never about — so it is rejected outright. + */ +const isSteps = (value: unknown): value is Step[] => + Array.isArray(value) && + value.every(step => { + if (typeof step !== 'object' || step === null) return false + const kind = (step as { kind?: unknown }).kind + if (kind === 'buffer' || kind === 'flush') return true + const event = (step as { event?: unknown }).event + // The event name must be one this harness listens for, or 'never released' + // would just mean 'nobody was listening'. + return kind === 'emit' && typeof event === 'string' && (EMITTABLE as readonly string[]).includes(event) + }) + +describe('event buffer', () => { + it('releases the same events as upstream for the same sequence', async () => { + await fuzz({ + target: 'buffer:differential', + runs: 300, + generate: generateSteps, + check: steps => { + if (!isSteps(steps)) return [] + + const local = observe(() => makeEventBuffer(silentLogger), steps) + const remote = observe(() => upstream.makeEventBuffer(silentLogger) as never, steps) + + // `preservePresence: true`, as the pure-helper and message-wire targets + // already use. Without it, one buffer deleting an optional property and + // the other leaving it holding `undefined` compare equal — and the + // generated payloads are full of such fields (`participant`, `name`, + // `notify`, the history-sync metadata). A consumer tells them apart with + // `Object.keys`, spread or `in`, so a consolidation change that altered + // the public event shape is a real difference, not a representation one. + // `coerceScalars: false` alongside it. Coercion exists so a Rust u64 can be + // compared against a protobufjs Long — these are plain event payloads, and + // the generator emits numeric-looking strings on purpose, so folding + // `conversation: '0'` together with the number `0` would hide a + // consolidation that changed a text value's runtime type. A consumer sees + // that difference through `typeof`, `===` and arithmetic. + const strict = { preservePresence: true, coerceScalars: false } + if (equivalent(local, remote, strict)) return [] + return { + target: 'buffer:differential', + input: steps, + // Recorded under the same policy the gate used. The registry reads + // these values, and the default normalisation puts back exactly what + // the gate had held apart: `'0'` and `0` fold together and an + // explicitly-present `undefined` disappears. A type or presence + // regression occurring in a sequence that *also* shows the documented + // release-order difference then rendered as two records differing only + // by permutation, and `event-buffer-release-order` excused the whole + // finding. + local: normalise(local, 0, strict), + upstream: normalise(remote, 0, strict), + detail: 'the two buffers released different events for the same sequence' + } + } + }) + }) + + it('never releases fewer events than upstream', async () => { + // Loss is the severe failure mode and it is silent by construction: a + // `messages.upsert` that never arrives looks exactly like a message that was + // never sent. Ordering differences are the differential target's subject — + // this one only asks whether anything went missing. + // + // The comparison is against upstream rather than against the input, because + // both libraries legitimately consolidate some payloads away entirely: a + // `messages.delete` with no keys releases nothing on either side, and calling + // that a loss would be wrong. + await fuzz({ + target: 'buffer:conservation', + runs: 300, + generate: generateSteps, + check: steps => { + if (!isSteps(steps)) return [] + + const count = (observations: readonly Observation[]) => { + const tally = new Map() + for (const entry of observations) { + if (!('released' in entry)) continue + tally.set(entry.released, (tally.get(entry.released) ?? 0) + 1) + } + return tally + } + + const local = count(observe(() => makeEventBuffer(silentLogger), steps)) + const remote = count(observe(() => upstream.makeEventBuffer(silentLogger) as never, steps)) + + const missing = [...remote] + .filter(([event, total]) => (local.get(event) ?? 0) < total) + .map(([event, total]) => `${event} (${local.get(event) ?? 0} of ${total})`) + if (missing.length === 0) return [] + + return { + target: 'buffer:conservation', + input: steps, + local: Object.fromEntries(local), + upstream: Object.fromEntries(remote), + detail: `released fewer events than upstream: ${missing.join(', ')}` + } + } + }) + }) + + it('has nothing left to release after a flush', () => { + // A plain invariant, not a fuzz target: `flush` returning true forever would + // make the drain loop in `observe` spin, so it is pinned separately and first. + const buffer = makeEventBuffer(silentLogger) + buffer.buffer() + buffer.emit('chats.upsert', [{ id: '15551234567@s.whatsapp.net' }] as never) + assert.equal(buffer.flush(), true, 'the first flush releases the buffered events') + assert.equal(buffer.flush(), false, 'a second flush has nothing to release') + }) +}) diff --git a/src/__fuzz__/corpus/bridge-adapt-total.json b/src/__fuzz__/corpus/bridge-adapt-total.json new file mode 100644 index 00000000..fa14f63f --- /dev/null +++ b/src/__fuzz__/corpus/bridge-adapt-total.json @@ -0,0 +1,19 @@ +[ + { + "note": "a declared event type with no data slot throws instead of returning null", + "input": [ + { + "type": "dirty_state" + } + ] + }, + { + "note": "type \"__proto__\" resolves to a non-function and throws \"adapter is not a function\"", + "input": [ + { + "type": "__proto__", + "data": {} + } + ] + } +] diff --git a/src/__fuzz__/corpus/bridge-adapt-unknown.json b/src/__fuzz__/corpus/bridge-adapt-unknown.json new file mode 100644 index 00000000..9840531b --- /dev/null +++ b/src/__fuzz__/corpus/bridge-adapt-unknown.json @@ -0,0 +1,9 @@ +[ + { + "note": "an event type that resolves through Object.prototype: the inherited function is called and its result treated as a canonical event", + "input": { + "type": "constructor", + "data": {} + } + } +] diff --git a/src/__fuzz__/corpus/buffer-differential.json b/src/__fuzz__/corpus/buffer-differential.json new file mode 100644 index 00000000..df7010d2 --- /dev/null +++ b/src/__fuzz__/corpus/buffer-differential.json @@ -0,0 +1,36 @@ +[ + { + "note": "two buffered events release in a different order than upstream", + "input": [ + { + "kind": "buffer" + }, + { + "kind": "emit", + "event": "contacts.upsert", + "data": [ + { + "id": "15551234567@s.whatsapp.net" + } + ] + }, + { + "kind": "emit", + "event": "message-receipt.update", + "data": [ + { + "key": { + "remoteJid": "15551234567@s.whatsapp.net", + "id": "A1", + "fromMe": false + }, + "receipt": { + "userJid": "15551234567@s.whatsapp.net", + "readTimestamp": 1 + } + } + ] + } + ] + } +] diff --git a/src/__fuzz__/corpus/proto-decode-parity.json b/src/__fuzz__/corpus/proto-decode-parity.json new file mode 100644 index 00000000..610349d8 --- /dev/null +++ b/src/__fuzz__/corpus/proto-decode-parity.json @@ -0,0 +1,13 @@ +[ + { + "note": "a 64-bit field above Number.MAX_SAFE_INTEGER: the bridge decoder throws where protobufjs returns a Long", + "input": { + "path": "Message", + "message": { + "videoMessage": { + "fileLength": "9007199254740992" + } + } + } + } +] diff --git a/src/__fuzz__/corpus/proto-encode-bytes.json b/src/__fuzz__/corpus/proto-encode-bytes.json new file mode 100644 index 00000000..db72df20 --- /dev/null +++ b/src/__fuzz__/corpus/proto-encode-bytes.json @@ -0,0 +1,25 @@ +[ + { + "note": "a field whose type the bridge does not implement is silently omitted rather than reported", + "input": { + "path": "MessageContextInfo", + "message": { + "botMetadata": { + "avatarMetadata": {} + } + } + } + }, + { + "note": "repeated scalars: unpacked from the bridge (08 00 08 01), packed from protobufjs (0a 02 00 01). Lives here, not in proto-field-packing.json: `proto:field-packing` is a finding target the encode-bytes run emits, and the corpus is keyed on the fuzz() target, so a file named after it is never replayed.", + "input": { + "path": "BotCapabilityMetadata", + "message": { + "capabilities": [ + 0, + 1 + ] + } + } + } +] diff --git a/src/__fuzz__/corpus/proto-field-names.json b/src/__fuzz__/corpus/proto-field-names.json new file mode 100644 index 00000000..f17f7344 --- /dev/null +++ b/src/__fuzz__/corpus/proto-field-names.json @@ -0,0 +1,10 @@ +[ + { + "note": "the bridge round-trips deviceAgentID as deviceAgentId and drops the upstream spelling on encode", + "input": { + "path": "SyncActionValue.ChatAssignmentAction", + "field": "deviceAgentID", + "kind": 2 + } + } +] diff --git a/src/__fuzz__/corpus/proto-integers.json b/src/__fuzz__/corpus/proto-integers.json new file mode 100644 index 00000000..3d43ffee --- /dev/null +++ b/src/__fuzz__/corpus/proto-integers.json @@ -0,0 +1,11 @@ +[ + { + "note": "a double above FLT_MAX in a 32-bit float field: the bridge throws \"invalid float32\", protobufjs encodes it silently. Exact FLT_MAX is accepted by both.", + "input": { + "path": "AIRichResponseLatexMetadata.AIRichResponseLatexExpression", + "message": { + "fontHeight": 3.5e+38 + } + } + } +] diff --git a/src/__fuzz__/corpus/proto-mutation-agreement.json b/src/__fuzz__/corpus/proto-mutation-agreement.json new file mode 100644 index 00000000..81abcaeb --- /dev/null +++ b/src/__fuzz__/corpus/proto-mutation-agreement.json @@ -0,0 +1,10 @@ +[ + { + "note": "field 1 of SyncActionValue is `optional int64 timestamp`, written here as wire type 2 wrapping the legal `08 20`. protobufjs runs its generated `reader.int64()` regardless of the wire type, flattens the wrapper and reports timestamp 32; the bridge treats the mismatched field as unknown and reports {}. Pinned here because the nesting-bomb mutator only reaches it on some seeds, and proto-wire-type-mismatch-ignored-upstream would otherwise be reported as an allowlist entry that excuses nothing.", + "input": { + "path": "SyncActionValue", + "mutator": "nesting-bomb", + "bytes": { "__bytes__": "CgIIIA==" } + } + } +] diff --git a/src/__fuzz__/corpus/proto-presence.json b/src/__fuzz__/corpus/proto-presence.json new file mode 100644 index 00000000..c96774e6 --- /dev/null +++ b/src/__fuzz__/corpus/proto-presence.json @@ -0,0 +1,10 @@ +[ + { + "note": "mediaKeyDomain is proto3 optional; set to its zero value the bridge encodes nothing at all", + "input": { + "path": "Message.MMSThumbnailMetadata", + "field": "mediaKeyDomain", + "kind": 1 + } + } +] diff --git a/src/__fuzz__/corpus/proto-type-coverage.json b/src/__fuzz__/corpus/proto-type-coverage.json new file mode 100644 index 00000000..ec226584 --- /dev/null +++ b/src/__fuzz__/corpus/proto-type-coverage.json @@ -0,0 +1,6 @@ +[ + { + "note": "a message type upstream declares that the bridge codec does not implement", + "input": "BotAvatarMetadata" + } +] diff --git a/src/__fuzz__/corpus/pure-cleanmessage.json b/src/__fuzz__/corpus/pure-cleanmessage.json new file mode 100644 index 00000000..bac4ac62 --- /dev/null +++ b/src/__fuzz__/corpus/pure-cleanmessage.json @@ -0,0 +1,12 @@ +[ + { + "note": "key with no remoteJid/participant: upstream normalises to \"\", baileyrs leaves undefined", + "input": [ + { + "key": {} + }, + "", + "" + ] + } +] diff --git a/src/__fuzz__/corpus/pure-encodenewslettermessage.json b/src/__fuzz__/corpus/pure-encodenewslettermessage.json new file mode 100644 index 00000000..4c9add9a --- /dev/null +++ b/src/__fuzz__/corpus/pure-encodenewslettermessage.json @@ -0,0 +1,12 @@ +[ + { + "note": "unpaired UTF-16 surrogate: protobufjs emits WTF-8 (ed bf bf), the Rust encoder substitutes U+FFFD (ef bf bd)", + "input": [ + { + "imageMessage": { + "url": "\udfff" + } + } + ] + } +] diff --git a/src/__fuzz__/corpus/pure-generateforwardmessagecontent.json b/src/__fuzz__/corpus/pure-generateforwardmessagecontent.json new file mode 100644 index 00000000..8222dedc --- /dev/null +++ b/src/__fuzz__/corpus/pure-generateforwardmessagecontent.json @@ -0,0 +1,14 @@ +[ + { + "note": "baileyrs writes contextInfo onto the caller's own message object; upstream leaves the argument untouched", + "input": [ + { + "key": {}, + "message": { + "senderKeyDistributionMessage": {} + } + }, + null + ] + } +] diff --git a/src/__fuzz__/corpus/pure-getaggregatevotesinpollmessage.json b/src/__fuzz__/corpus/pure-getaggregatevotesinpollmessage.json new file mode 100644 index 00000000..4372e86a --- /dev/null +++ b/src/__fuzz__/corpus/pure-getaggregatevotesinpollmessage.json @@ -0,0 +1,33 @@ +[ + { + "note": "same votes aggregated into the same buckets, emitted in a different order", + "input": [ + { + "pollUpdates": [ + { + "vote": { + "selectedOptions": [ + { + "__bytes__": "" + } + ] + } + }, + { + "pollUpdateMessageKey": { + "remoteJid": " " + }, + "vote": { + "selectedOptions": [ + { + "__bytes__": "XA==" + } + ] + } + } + ] + }, + "" + ] + } +] diff --git a/src/__fuzz__/corpus/pure-getbinarynodemessages.json b/src/__fuzz__/corpus/pure-getbinarynodemessages.json new file mode 100644 index 00000000..f53d3ad8 --- /dev/null +++ b/src/__fuzz__/corpus/pure-getbinarynodemessages.json @@ -0,0 +1,18 @@ +[ + { + "note": "a child whose content is not a decodable WebMessageInfo: upstream throws \"illegal buffer\", baileyrs returns an empty message", + "input": [ + { + "tag": "iq", + "attrs": {}, + "content": [ + { + "tag": "message", + "attrs": {}, + "content": "" + } + ] + } + ] + } +] diff --git a/src/__fuzz__/corpus/pure-gethistorymsg.json b/src/__fuzz__/corpus/pure-gethistorymsg.json new file mode 100644 index 00000000..ed4f65ce --- /dev/null +++ b/src/__fuzz__/corpus/pure-gethistorymsg.json @@ -0,0 +1,8 @@ +[ + { + "note": "no history-sync notification present: upstream returns undefined, baileyrs throws Boom 400", + "input": [ + {} + ] + } +] diff --git a/src/__fuzz__/corpus/pure-tonumber.json b/src/__fuzz__/corpus/pure-tonumber.json new file mode 100644 index 00000000..a863006e --- /dev/null +++ b/src/__fuzz__/corpus/pure-tonumber.json @@ -0,0 +1,12 @@ +[ + { + "note": "a Long-shaped pair whose high word upstream drops (returns low), baileyrs reconstructs", + "input": [ + { + "low": 1, + "high": 1, + "unsigned": false + } + ] + } +] diff --git a/src/__fuzz__/coverage.fuzz.test.ts b/src/__fuzz__/coverage.fuzz.test.ts new file mode 100644 index 00000000..0d9c10c0 --- /dev/null +++ b/src/__fuzz__/coverage.fuzz.test.ts @@ -0,0 +1,70 @@ +/** + * Coverage ledger enforcement. + * + * This is the piece that makes the fuzz suite grow by itself. It enumerates every + * function baileyrs and Baileys both export and fails when one of them is neither + * fuzzed nor explicitly excused. Adding a helper to the public surface therefore + * turns the pull request red until somebody decides which it is — instead of the + * suite quietly covering a shrinking fraction of the API as it grows. + * + * It also fails on excuses for exports that no longer exist, so the ledger cannot + * accumulate entries nobody can trace back to anything. + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { EXCLUDED_EXPORTS, PURE_TARGET_NAMES } from './targets.ts' + +const upstream = (await import('baileys')) as unknown as Record +const local = (await import('../index.ts')) as unknown as Record + +const sharedFunctionExports = Object.keys(upstream) + .filter(name => typeof upstream[name] === 'function' && typeof local[name] === 'function') + .toSorted() + +describe('fuzz coverage ledger', () => { + it('finds a meaningful shared surface at all', () => { + // A guard on the guard: if the import shape ever changes, this test must not + // pass by comparing two empty sets. + assert.ok( + sharedFunctionExports.length > 100, + `expected a large shared export surface, found ${sharedFunctionExports.length}` + ) + }) + + it('accounts for every shared function export', () => { + const covered = new Set(PURE_TARGET_NAMES) + const excused = new Set(Object.keys(EXCLUDED_EXPORTS)) + const unaccounted = sharedFunctionExports.filter(name => !covered.has(name) && !excused.has(name)) + + assert.deepEqual( + unaccounted, + [], + [ + `${unaccounted.length} shared export(s) are neither fuzzed nor excused:`, + ...unaccounted.map(name => ` - ${name}`), + '', + 'Add each to PURE_TARGET_NAMES (with a generator in pure-differential.fuzz.test.ts)', + 'or to EXCLUDED_EXPORTS in src/__fuzz__/targets.ts with the reason it cannot be fuzzed.' + ].join('\n') + ) + }) + + it('keeps no ledger entry for an export that is gone', () => { + const shared = new Set(sharedFunctionExports) + const stale = [...PURE_TARGET_NAMES, ...Object.keys(EXCLUDED_EXPORTS)].filter(name => !shared.has(name)) + assert.deepEqual(stale, [], `ledger entries with no matching shared export: ${stale.join(', ')}`) + }) + + it('never lists an export as both fuzzed and excused', () => { + const excused = new Set(Object.keys(EXCLUDED_EXPORTS)) + const both = PURE_TARGET_NAMES.filter(name => excused.has(name)) + assert.deepEqual(both, [], `listed twice: ${both.join(', ')}`) + }) + + it('gives every exclusion a reason worth reading', () => { + for (const [name, reason] of Object.entries(EXCLUDED_EXPORTS)) { + assert.ok(reason.length > 10, `exclusion for ${name} needs a real reason, got ${JSON.stringify(reason)}`) + } + }) +}) diff --git a/src/__fuzz__/generators/binary-node.ts b/src/__fuzz__/generators/binary-node.ts new file mode 100644 index 00000000..d1d04bd1 --- /dev/null +++ b/src/__fuzz__/generators/binary-node.ts @@ -0,0 +1,536 @@ +/** + * BinaryNode generator. + * + * The node accessors in `src/WABinary/generic-utils.ts` are the first thing every + * stanza handler touches, and they are all shape-tolerant by design: a missing + * child, a string where bytes were expected, an attribute that is not a number. + * The generator therefore produces well-formed stanzas most of the time and + * violates one property at a time the rest of the time, which is how real server + * traffic degrades. + */ + +import type { BinaryNode } from '../../Types/index.ts' +import type { Random } from '../harness/random.ts' +import { generateString } from './values.ts' + +const TAGS = [ + 'iq', + 'message', + 'error', + 'result', + 'item', + 'participant', + 'participants', + 'device', + 'devices', + 'user', + 'list', + 'add', + 'remove', + 'promote', + 'demote', + 'stream:error', + 'ack', + 'receipt', + 'notification', + 'offline', + 'enc', + 'skmsg', + 'plaintext', + 'success', + 'failure', + 'not-authorized', + 'conflict', + 'xmlstreamend', + '', + 'UPPER', + 'weird tag' +] as const + +const ATTRIBUTE_KEYS = [ + 'id', + 'type', + 'from', + 'to', + 'jid', + 'lid', + 'participant', + 'code', + 'text', + 't', + 'v', + 'edit', + 'offline', + 'count', + 'error', + 'reason', + 'class', + 'xmlns', + '', + '__proto__' +] as const + +const ATTRIBUTE_VALUES = [ + '', + '0', + '1', + '-1', + '200', + '401', + '403', + '404', + '408', + '428', + '440', + '500', + '515', + 'not-a-number', + '15551234567@s.whatsapp.net', + '120363000000000000@g.us', + 'status@broadcast', + 'true', + 'false', + '9007199254740993', + '1e3', + '0x10', + ' 42 ', + 'NaN' +] as const + +const generateAttributes = (random: Random): Record => { + const attributes: Record = {} + const count = random.int(0, 5) + for (let index = 0; index < count; index++) { + const key = random.pick(ATTRIBUTE_KEYS) + const value = random.bool(0.85) ? random.pick(ATTRIBUTE_VALUES) : generateString(random) + // `__proto__` has to stay an own property: assigning it would call the + // inherited setter, drop the key, and never hand the hostile shape to a + // BinaryNode consumer at all. + Object.defineProperty(attributes, key, { value, enumerable: true, writable: true, configurable: true }) + } + return attributes +} + +const generateContent = (random: Random, depth: number): BinaryNode['content'] => + random.weighted<() => BinaryNode['content']>([ + [depth > 0 ? 5 : 0, () => Array.from({ length: random.int(0, 4) }, () => generateBinaryNode(random, depth - 1))], + [3, () => undefined], + [2, () => random.pick(ATTRIBUTE_VALUES)], + [2, () => random.bytes(random.int(0, 24))], + [1, () => generateString(random)], + [1, () => []] + ])() + +export const generateBinaryNode = (random: Random, depth = 2): BinaryNode => ({ + tag: random.pick(TAGS), + attrs: generateAttributes(random), + content: generateContent(random, depth) +}) + +/** A stanza shaped like a real error reply, so the error-path helpers see their branch. */ +export const generateErrorNode = (random: Random): BinaryNode => ({ + tag: random.pick(['iq', 'stream:error', 'ack', 'message']), + attrs: { type: 'error', id: String(random.int(1, 9999)) }, + content: [ + { + tag: 'error', + attrs: { code: random.pick(ATTRIBUTE_VALUES), text: random.pick(['forbidden', 'not-acceptable', '']) }, + content: random.bool() ? [{ tag: random.pick(TAGS), attrs: {} }] : undefined + } + ] +}) + +/** + * An IQ reply that carries an `` child only some of the time. + * + * `assertNodeErrorFree` throws exactly when that child is present, and + * `generateErrorNode` always builds one — so all 250 inputs took the throwing + * path, both sides threw, and the comparator called it agreement. The successful + * path was never compared at all: an implementation that started rejecting every + * ordinary error-free stanza would have passed this target. + * + * The error case stays the common one, since that is where the status-code + * parsing lives; roughly a third are clean replies. + */ +export const generateResponseNode = (random: Random): BinaryNode => { + const children: BinaryNode[] = [] + if (random.bool(0.65)) { + children.push({ + tag: 'error', + attrs: { code: random.pick(ATTRIBUTE_VALUES), text: random.pick(['forbidden', 'not-acceptable', '']) } + }) + } + // Ordinary siblings, so a clean reply is a realistic stanza rather than an + // empty one — `assertNodeErrorFree` has to ignore all of them. + for (let index = random.int(0, 2); index > 0; index--) { + children.push({ tag: random.pick(['item', 'participant', 'list', 'enc']), attrs: generateAttributes(random) }) + } + + return { + tag: random.pick(['iq', 'ack', 'result', 'message']), + attrs: { id: String(random.int(1, 9999)), ...(random.bool(0.5) ? { type: 'error' } : {}) }, + content: random.bool(0.9) ? random.shuffle(children) : random.pick([[], undefined]) + } +} + +/** + * A parent whose children actually carry the tag the accessor will query. + * + * `generateBinaryNode` draws the child tag and the child content independently, + * and the target draws the queried tag independently again — so on the fixed + * seed none of the 250 `getBinaryNodeChildBuffer` cases and none of the 250 + * `getBinaryNodeChildUInt` cases found a child with byte content, and + * `getBinaryNodeChildString` found one. Every one of those compared `undefined` + * against `undefined`: the buffer extraction, the UTF-8 decode and the + * big-endian accumulation were never run. + * + * So the child tag is drawn from the same pool the query is, and the content is + * weighted toward the types these accessors are about — with the wrong types + * still drawn, since returning `undefined` for a string is also behaviour worth + * pinning. + */ +export const generateTaggedNode = (random: Random, tags: readonly string[], queried?: string): BinaryNode => ({ + tag: random.pick(['iq', 'notification', 'message']), + attrs: generateAttributes(random), + content: Array.from({ length: random.int(1, 4) }, () => ({ + // The tag the caller is about to query, when it is known. Drawing the child + // tag and the queried tag independently left the accessor looking for a + // child that is usually not there: measured, 52 of 250 buffer cases reached + // a value. Passing the query in first raises that to most of them, while the + // 15% that draw elsewhere keep the "no such child" path covered. + tag: + queried !== undefined && random.bool(0.85) ? queried : random.bool(0.8) ? random.pick(tags) : random.pick(TAGS), + attrs: generateAttributes(random), + // Thunks, so only the chosen branch draws from the stream. Passing values + // built every branch and discarded all but one, which couples the + // deterministic sequence to draws that are never used — a later weight edit + // then shifts every downstream value. + content: random.weighted<() => BinaryNode['content']>([ + // Lengths span the widths getBinaryNodeChildUInt is asked for, including + // buffers shorter than the requested length. + [5, () => random.bytes(random.pick([0, 1, 2, 3, 4, 8, 16]))], + [3, () => Buffer.from(random.bytes(random.pick([1, 2, 4, 8])))], + [3, () => generateString(random)], + [2, () => random.pick(ATTRIBUTE_VALUES)], + [1, () => undefined], + [1, () => []] + ])() + })) +}) + +/** + * A node whose children are ``s carrying key/value attributes, for the + * dictionary reducer. + * + * `reduceBinaryNodeToDictionary` reads two attribute spellings — the key is + * `attrs.name` or, when that is absent, `attrs.config_code`, and the value is + * `attrs.value || attrs.config_value`. Only the first pair was generated, and + * neither config key is in the generic attribute pool, so the fallback branch + * never ran and a regression that dropped or miskeyed a config-code entry + * stayed green. All four combinations are drawn now, including the ones where + * the value side is missing and the `||` has to fall through. + */ +const dictionaryItemAttrs = (random: Random): Record => { + const key = random.pick(ATTRIBUTE_KEYS) + const value = random.pick(ATTRIBUTE_VALUES) + return random.weighted<() => Record>([ + [4, () => ({ name: key, value })], + [3, () => ({ config_code: key, config_value: value })], + // Mixed spellings: the key from one pair, the value from the other. Both + // resolutions are independent in the reducer, so both crossings are real. + [2, () => ({ name: key, config_value: value })], + [2, () => ({ config_code: key, value })], + // A key with no value at all: `attrs.value || attrs.config_value` is then + // undefined, which the reducer stores as-is. + [1, () => ({ name: key })], + [1, () => ({ config_code: key })] + ])() +} + +export const generateDictionaryNode = (random: Random): BinaryNode => ({ + tag: random.pick(['props', 'list', 'dict']), + attrs: generateAttributes(random), + content: Array.from({ length: random.int(0, 6) }, () => ({ + tag: random.bool(0.8) ? 'item' : random.pick(TAGS), + attrs: random.bool(0.8) ? dictionaryItemAttrs(random) : generateAttributes(random), + content: undefined + })) +}) + +/** + * A media-retry reply stanza, shaped the way the server actually sends one. + * + * `decodeMediaRetryNode` reads `` before anything else and does it with a + * non-null assertion, so a node without that child throws on the first line. + * Feeding it only malformed nodes therefore exercises exactly one branch — both + * implementations throw, the comparator calls that agreement, and the status + * mapping, the error branch and the ciphertext extraction are never compared at + * all. + * + * So the valid shape is the common case here and the malformed ones are + * variations on it: `` present (the failure path, whose code drives + * `getStatusCodeForMediaRetry`), `` with both `enc_p` and `enc_iv` (the + * success path), and each of those with a piece missing. + */ +export const generateMediaRetryNode = (random: Random): BinaryNode => { + const content: BinaryNode[] = [] + + // Usually present — without it the decoder cannot reach any other branch. + if (random.bool(0.85)) { + content.push({ + tag: 'rmr', + attrs: { + jid: random.pick(['15551234567@s.whatsapp.net', '120363000000000000@g.us', '']), + from_me: random.pick(['true', 'false', '']), + ...(random.bool(0.5) ? { participant: random.pick(['15550000000@s.whatsapp.net', '']) } : {}) + } + }) + } + + if (random.bool(0.45)) { + // The error path. The codes are the ones getStatusCodeForMediaRetry maps, + // plus values outside its table. + content.push({ + tag: 'error', + attrs: { code: random.pick(['0', '1', '2', '3', '4', '5', '404', '-1', 'not-a-number', '']) } + }) + } else if (random.bool(0.8)) { + // The success path, sometimes missing one half of the key material. + const encrypted: BinaryNode[] = [] + if (random.bool(0.85)) encrypted.push({ tag: 'enc_p', attrs: {}, content: random.bytes(random.pick([0, 1, 32])) }) + if (random.bool(0.85)) encrypted.push({ tag: 'enc_iv', attrs: {}, content: random.bytes(random.pick([0, 12, 16])) }) + content.push({ tag: 'encrypt', attrs: {}, content: encrypted }) + } + + return { + tag: 'notification', + attrs: { id: String(random.int(1, 9999)), type: 'media-retry' }, + content + } +} + +/** + * A call stanza, using the tags `getCallStatusFromNode` actually switches on. + * + * The generic tag pool contains none of them, so every generated node fell to + * the `default` arm and the helper returned `ringing` for all 250 inputs — the + * whole mapping, including the `terminate` timeout branch, was untested while + * the target reported coverage of it. + */ +const CALL_TAGS = [ + 'offer', + 'offer_notice', + 'terminate', + 'preaccept', + 'transport', + 'relaylatency', + 'reject', + 'accept', + 'call', + '' +] as const + +export const generateCallNode = (random: Random): BinaryNode => ({ + tag: random.pick(CALL_TAGS), + attrs: { + // `terminate` splits on exactly this value, so it has to be drawn often. + ...(random.bool(0.6) ? { reason: random.pick(['timeout', 'declined', 'busy', '', 'TIMEOUT']) } : {}), + 'call-id': random.pick(['ABC123', '']), + from: random.pick(['15551234567@s.whatsapp.net', '']) + }, + content: random.bool(0.3) ? [{ tag: random.pick(TAGS), attrs: {} }] : undefined +}) + +/** + * A retry receipt carrying a session key bundle. + * + * `extractE2ESessionFromRetryReceipt` bails at the first `keys` lookup, and the + * generic node generator has no `keys` tag — so all 200 inputs returned `null` + * and none of the length validation, the registration-id parsing, the optional + * pre-key or the prefixed-public-key construction was ever compared. + * + * The lengths and the type byte are the interesting boundaries, so they are the + * thing that varies: 32 is the only accepted key length and 5 the only accepted + * bundle type, and each is drawn off-value often enough to exercise the reject + * paths as well as the accept one. + */ +export const generateRetryReceiptNode = (random: Random): BinaryNode => { + const key = (length: number) => random.bytes(length) + const keyLength = () => random.pick([32, 32, 32, 31, 33, 0]) + const uint = (bytes: number) => Buffer.from(random.bytes(bytes)) + + const keys: BinaryNode[] = [ + // Type byte 5 is the only one accepted; anything else must reject. + { tag: 'type', attrs: {}, content: Buffer.from([random.pick([5, 5, 5, 4, 6])]) }, + { tag: 'identity', attrs: {}, content: key(keyLength()) }, + { + tag: 'skey', + attrs: {}, + content: [ + { tag: 'id', attrs: {}, content: uint(3) }, + { tag: 'value', attrs: {}, content: key(keyLength()) }, + { tag: 'signature', attrs: {}, content: key(random.pick([64, 0, 32])) } + ] + } + ] + + // The optional pre-key: present about half the time, sometimes malformed. + if (random.bool(0.5)) { + keys.push({ + tag: 'key', + attrs: {}, + content: [ + { tag: 'id', attrs: {}, content: uint(3) }, + { tag: 'value', attrs: {}, content: key(keyLength()) } + ] + }) + } + + return { + tag: 'receipt', + attrs: { type: 'retry', from: '15551234567@s.whatsapp.net', id: String(random.int(1, 9999)) }, + content: [ + { tag: 'registration', attrs: {}, content: uint(4) }, + { tag: 'keys', attrs: {}, content: random.bool(0.9) ? keys : keys.slice(0, random.int(0, 2)) } + ] + } +} + +/** + * A `` stanza whose child content is a real, populated `WebMessageInfo`. + * + * `getBinaryNodeMessages` is the only accessor that decodes rather than reads: + * it picks every `` child and runs `WebMessageInfo.decode(...).toJSON()` + * over its bytes. The generic node generator never produces the tag and the + * payload together — the `message` tag exists in its pool, but its content is + * drawn independently, so it is a string, `undefined` or a couple of random bytes + * essentially every time. Both sides then throw, or both return `[]`, and the + * comparator reads that as agreement. The decoder and the JSON projection — where + * a divergence in field naming, int64 rendering or bytes encoding would actually + * show — were never reached. + * + * The bytes are written here by hand rather than by either library's encoder. An + * encoder-produced payload would make the target circular: a bug shared by the + * encoder and the decoder cancels out, and a bug in only one library's encoder + * feeds the two sides different-looking-but-equally-broken input. A literal wire + * encoding is what the server sends, so it is what the fixture is. + */ +const varint = (value: bigint): number[] => { + const bytes: number[] = [] + let remaining = value + do { + const byte = Number(remaining & 0x7fn) + remaining >>= 7n + bytes.push(remaining > 0n ? byte | 0x80 : byte) + } while (remaining > 0n) + return bytes +} + +const varintField = (field: number, value: bigint): number[] => [...varint(BigInt(field) << 3n), ...varint(value)] + +const bytesField = (field: number, payload: Uint8Array | number[]): number[] => [ + ...varint((BigInt(field) << 3n) | 2n), + ...varint(BigInt(payload.length)), + ...payload +] + +const stringField = (field: number, value: string): number[] => [...bytesField(field, Buffer.from(value, 'utf8'))] + +/** Timestamps span the boundary where a 64-bit field stops fitting in a JS number. */ +const TIMESTAMPS = [0n, 1n, 1700000000n, 4294967296n, 9007199254740991n, 9007199254740993n, 18446744073709551615n] + +/** Statuses 0-5 are defined; the rest are the unknown values a newer server sends. */ +const STATUSES = [0n, 1n, 2n, 3n, 4n, 5n, 6n, 99n] + +const webMessageInfo = (random: Random): number[] => { + const key = [ + ...stringField(1, random.pick(['15551234567@s.whatsapp.net', '120363000000000000@g.us', ''])), + ...varintField(2, random.bool() ? 1n : 0n), + ...stringField(3, random.bool(0.85) ? `3EB0${random.int(0, 0xffffff).toString(16).toUpperCase()}` : ''), + ...(random.bool(0.3) ? stringField(4, generateString(random)) : []) + ] + + // `conversation` and `extendedTextMessage` are the two shapes the accessor's + // callers actually read, and the second is nested — so a projection that + // flattens or renames a nested field shows up here and not in the first. + const message = random.bool(0.6) + ? stringField(1, generateString(random)) + : bytesField(6, [ + ...stringField(1, generateString(random)), + ...(random.bool(0.5) ? stringField(2, generateString(random)) : []) + ]) + + return [ + ...bytesField(1, key), + ...(random.bool(0.9) ? bytesField(2, message) : []), + ...varintField(3, random.pick(TIMESTAMPS)), + ...(random.bool(0.7) ? varintField(4, random.pick(STATUSES)) : []), + ...(random.bool(0.4) ? stringField(5, random.pick(['15550000000@s.whatsapp.net', ''])) : []), + ...(random.bool(0.5) ? stringField(19, generateString(random)) : []), + // Repeated string: written unpacked, which is the only legal form for it. + ...(random.bool(0.3) + ? Array.from({ length: random.int(1, 3) }, () => stringField(26, generateString(random))).flat() + : []), + // A bytes field, so the JSON projection's base64/array choice is compared. + ...(random.bool(0.4) ? bytesField(49, random.bytes(random.pick([0, 1, 32]))) : []) + ] +} + +export const generateMessageStanza = (random: Random): BinaryNode => { + const child = (): BinaryNode => { + const payload = webMessageInfo(random) + return { + tag: 'message', + attrs: { id: String(random.int(1, 9999)) }, + content: random.weighted([ + [8, Buffer.from(payload)], + // Truncated mid-field: the decoder has to reject rather than return a + // half-built message. + [1, Buffer.from(payload.slice(0, Math.max(0, payload.length - random.int(1, 4))))], + [1, Buffer.from(random.bytes(random.int(0, 16)))], + [1, generateString(random)], + [1, undefined] + ]) + } + } + + return { + tag: random.pick(['message', 'notification', 'iq']), + attrs: generateAttributes(random), + content: random.weighted<() => BinaryNode['content']>([ + [ + 8, + () => [ + // Non-`message` siblings have to be skipped, not decoded. + ...(random.bool(0.5) ? [{ tag: 'enc', attrs: {}, content: random.bytes(8) } as BinaryNode] : []), + ...Array.from({ length: random.int(1, 3) }, child), + ...(random.bool(0.3) ? [{ tag: 'participant', attrs: { jid: '' } } as BinaryNode] : []) + ] + ], + [1, () => []], + [1, () => undefined], + [1, () => Buffer.from(webMessageInfo(random))] + ])() + } +} + +/** + * A stream error shaped the way `getErrorCodeFromStreamError` reads one. + * + * That helper takes the *first child's tag* as the reason and the *parent's* + * `code` attribute as the status. `generateErrorNode` always names its child + * `error` and puts the code on the child, so every input resolved to reason + * `error` with no parent code and fell to the same bad-session default — the + * `conflict` mapping, the explicit status codes and the restart-required rewrite + * were never compared. + */ +const STREAM_REASONS = ['conflict', 'not-authorized', 'gone', 'bad-request', 'system-shutdown', 'xml-not-well-formed'] + +export const generateStreamErrorNode = (random: Random): BinaryNode => ({ + tag: 'stream:error', + // 515 is the restart-required code the helper rewrites the reason for. + attrs: random.bool(0.6) ? { code: random.pick(['401', '403', '408', '428', '440', '500', '515', '', 'nope']) } : {}, + content: random.bool(0.85) + ? [{ tag: random.pick(STREAM_REASONS), attrs: random.bool(0.4) ? { code: random.pick(ATTRIBUTE_VALUES) } : {} }] + : random.pick([[], undefined]) +}) diff --git a/src/__fuzz__/generators/bridge-event.ts b/src/__fuzz__/generators/bridge-event.ts new file mode 100644 index 00000000..a55fc8be --- /dev/null +++ b/src/__fuzz__/generators/bridge-event.ts @@ -0,0 +1,438 @@ +/** + * Bridge event generator. + * + * `src/Bridge/schema.ts` is an exhaustive table: one adapter entry per bridge + * event variant, and the type system fails the build if a variant is missing. So + * the list of event types is already enumerated for us — what is not enumerated + * is what the *payloads* look like when the runtime sends something a little + * different from what the `.d.ts` promised, which is the whole reason the + * anti-corruption layer exists. + * + * The field-name pool is taken from the properties `schema.ts` actually reads, so + * generated payloads hit real branches rather than being ignored wholesale. On + * top of that, a third of them are deliberately wrong-shaped: a string where an + * object belongs, a missing discriminator, a null where a list belongs. + */ + +import { KNOWN_BRIDGE_EVENT_TYPES } from '../../Bridge/schema.ts' +import type { Random } from '../harness/random.ts' +import { generateJid, generateMaybeJid, JID_SERVERS } from './jid.ts' +import { generateAnyValue, generateNumber, generateString } from './values.ts' + +export const BRIDGE_EVENT_TYPES: readonly string[] = [...KNOWN_BRIDGE_EVENT_TYPES].toSorted() + +/** The properties the adapters read, so generated payloads reach real branches. */ +const FIELD_NAMES = [ + 'action', + 'timestamp', + 'jid', + 'from', + 'id', + 'lid', + 'code', + 'source', + 'info', + 'platform', + 'message', + 'error', + 'chat_jid', + 'tag', + 'stanza_id', + 'reason', + 'payload', + 'participant_jid', + 'offline', + 'messages', + 'message_ids', + 'message_id', + 'label_id', + 'from_me', + 'call_id', + 'business_name', + 'attrs', + 'version', + 'unavailable_type', + 'unavailable', + 'type', + 'state', + 'participant', + 'participants', + 'removed', + 'picture_id', + 'muted', + 'archived', + 'pinned', + 'starred', + 'expiration' +] as const + +/** Discriminators the sync-action adapters switch on, in both casings seen in the wild. */ +const ACTION_TYPES = [ + 'add', + 'remove', + 'promote', + 'demote', + 'modify', + 'subject', + 'description', + 'announce', + 'not_announce', + 'notAnnounce', + 'Announce', + 'Promote', + 'locked', + 'unlocked', + 'ephemeral', + 'invite', + 'revoke_invite', + 'create', + 'delete', + 'link', + 'unlink', + 'unknown-action', + '' +] as const + +const value = (random: Random, depth: number): unknown => + random.weighted<() => unknown>([ + // The struct form as well as the string one: `asJidString` only accepts the + // struct, so a pool of strings alone leaves every JID-guarded branch dead. + [4, () => bridgeJid(random)], + [2, () => generateMaybeJid(random)], + [3, () => generateNumber(random)], + [3, () => random.bool()], + [2, () => generateString(random)], + [2, () => random.pick(ACTION_TYPES)], + [2, () => undefined], + [1, () => null], + [depth > 0 ? 3 : 0, () => payload(random, depth - 1)], + [depth > 0 ? 2 : 0, () => Array.from({ length: random.int(0, 3) }, () => payload(random, depth - 1))], + [1, () => generateAnyValue(random)] + ])() + +const payload = (random: Random, depth = 2): Record => { + const data: Record = {} + const keyCount = random.int(0, 6) + for (let index = 0; index < keyCount; index++) { + const key = random.pick(FIELD_NAMES) + Object.defineProperty(data, key, { + value: value(random, depth), + enumerable: true, + writable: true, + configurable: true + }) + } + return data +} + +/** + * A JID the way the *bridge* serialises one: a struct, not a string. + * + * `asJidString` runs `isBridgeJid` first and wants `{ user: string, server: + * string }`. Every JID this generator produced was a string, so every adapter + * field that goes through `asJidString` read `undefined` — which is most of the + * guard clauses in the table, and the reason those 22 types could never adapt to + * anything. The string form is still drawn: `pair_success` deliberately accepts + * both, and everywhere else it is the shape that has to be rejected. + */ +const validBridgeJid = (random: Random): Record => { + const [user, server] = generateJid(random).split('@') + return { + user: user ?? '', + server: server ?? random.pick([...JID_SERVERS]), + ...(random.bool(0.3) ? { agent: random.int(0, 3) } : {}), + ...(random.bool(0.3) ? { device: random.int(0, 5) } : {}) + } +} + +const bridgeJid = (random: Random): unknown => + random.weighted<() => unknown>([ + [8, () => validBridgeJid(random)], + [2, () => generateJid(random)], + [1, () => generateMaybeJid(random)], + // Struct-shaped but not a JID: `user`/`server` of the wrong type must be rejected. + [1, () => ({ user: generateNumber(random), server: random.pick([...JID_SERVERS]) })] + ])() + +/** + * A JID for a field an adapter *gates* on. + * + * `bridgeJid` mixes in the shapes that have to be rejected, which is right for + * optional fields but wrong for a guard: `chat_presence` needs a valid `chat` + * *and* `sender` *and* a canonical `state`, and at one-in-three each that clears + * eight tries only nine times in ten. The rejected shapes still arrive here + * through the generic payload, which every other bridge target draws from. + */ +const jidField = (random: Random): unknown => (random.bool(0.9) ? validBridgeJid(random) : bridgeJid(random)) + +const sourceBlock = (random: Random): Record => ({ + chat: jidField(random), + sender: jidField(random), + is_group: random.bool(), + is_from_me: random.bool(), + sender_alt: random.bool(0.4) ? bridgeJid(random) : undefined, + recipient_alt: random.bool(0.3) ? bridgeJid(random) : undefined +}) + +const infoBlock = (random: Random): Record => ({ + id: generateString(random), + source: sourceBlock(random), + timestamp: generateNumber(random), + push_name: random.bool(0.7) ? generateString(random) : undefined, + is_offline: random.bool(0.3), + edit: random.bool(0.3) ? random.pick(['1', '2', '7', '']) : undefined +}) + +const callAction = (random: Random): Record => ({ + type: random.pick(['offer', 'pre_accept', 'preaccept', 'transport', 'relaylatency', 'accept', 'reject', 'terminate']), + call_id: random.bool(0.9) ? generateString(random) : undefined, + call_creator: random.bool(0.6) ? bridgeJid(random) : undefined, + caller_pn: random.bool(0.5) ? bridgeJid(random) : undefined, + is_video: random.bool(), + joinable: random.bool(), + audio: random.bool(0.4) ? [generateString(random)] : undefined, + duration: random.bool(0.5) ? generateNumber(random) : undefined +}) + +const groupParticipant = (random: Random): Record => ({ + jid: jidField(random), + type: random.pick(['participant', 'admin', 'superadmin', 'other', '']), + phone_number: random.bool(0.5) ? bridgeJid(random) : undefined, + lid: random.bool(0.4) ? bridgeJid(random) : undefined, + display_name: random.bool(0.4) ? generateString(random) : undefined, + join_time: random.bool(0.4) ? generateNumber(random) : undefined +}) + +const groupAction = (random: Random): Record => ({ + type: random.pick(ACTION_TYPES), + participants: random.bool(0.6) ? Array.from({ length: random.int(0, 3) }, () => groupParticipant(random)) : undefined, + subject: generateString(random), + description: generateString(random), + // `ephemeral` is dropped outright when this is absent, so it is usually present. + expiration: random.bool(0.8) ? generateNumber(random) : undefined, + code: generateString(random), + mode: generateString(random), + enabled: random.bool() +}) + +/** + * Payloads shaped the way each adapter reads them. + * + * The generic `payload` above draws 0-6 keys out of forty flat names, so it + * essentially never produces the *combination* an adapter needs — `from` and + * `call_id` together, a nested `source: { chat, sender }`, an `action` object + * with a discriminator. Measured against the declared table, 22 of the 58 event + * types never once adapted to anything: every one of those runs returned `null` + * and a coverage check that only watches for throws called that a pass. + * + * So each of those types gets the shape its adapter actually reads. Fields are + * still fuzzed inside that shape — the point is to get past the guard clause and + * into the mapping, not to hand the adapter a fixture it cannot fail on. + */ +const SHAPED: Record Record> = { + message: random => ({ info: infoBlock(random), message: { conversation: generateString(random) } }), + undecryptable_message: random => ({ + info: infoBlock(random), + is_unavailable: random.bool(), + unavailable_type: random.bool(0.6) ? generateString(random) : undefined, + decrypt_fail_mode: random.bool(0.5) ? generateString(random) : undefined + }), + receipt: random => ({ + source: sourceBlock(random), + message_ids: random.bool(0.85) ? Array.from({ length: random.int(1, 3) }, () => generateString(random)) : [], + timestamp: generateNumber(random), + type: random.pick(['read', 'read-self', 'played', 'inactive', 'delivery', '', 'nonsense']) + }), + push_name_update: random => ({ jid: jidField(random), new_push_name: generateString(random) }), + contact_update: random => ({ + jid: jidField(random), + action: { fullName: generateString(random), first_name: generateString(random), username: generateString(random) } + }), + picture_update: random => ({ + jid: jidField(random), + removed: random.bool(), + author: random.bool(0.6) ? bridgeJid(random) : undefined, + picture_id: generateString(random) + }), + presence: random => ({ from: jidField(random), unavailable: random.bool(), last_seen: generateNumber(random) }), + chat_presence: random => ({ + source: sourceBlock(random), + // Anything but these two drops the event, so they are the overwhelming draw. + state: random.weighted([ + [6, 'composing'], + [6, 'paused'], + [1, ''], + [1, 'recording'] + ]), + media: random.pick(['audio', '', 'video']) + }), + group_update: random => ({ + group_jid: jidField(random), + action: groupAction(random), + notification_id: generateString(random), + action_index: generateNumber(random), + participant: random.bool(0.6) ? bridgeJid(random) : undefined, + timestamp: generateNumber(random), + is_lid_addressing_mode: random.bool() + }), + archive_update: random => ({ jid: jidField(random), action: { archived: random.bool() } }), + pin_update: random => ({ + jid: jidField(random), + timestamp: generateNumber(random), + action: { pinned: random.bool() } + }), + mute_update: random => ({ + jid: jidField(random), + timestamp: generateNumber(random), + action: { muted: random.bool(), mute_end_timestamp: generateNumber(random) } + }), + star_update: random => ({ + chat_jid: jidField(random), + message_id: generateString(random), + from_me: random.bool(), + participant_jid: random.bool(0.5) ? bridgeJid(random) : undefined, + action: { starred: random.bool() } + }), + mark_chat_as_read_update: random => ({ jid: jidField(random), action: { read: random.bool() } }), + incoming_call: random => ({ + from: jidField(random), + action: callAction(random), + timestamp: generateNumber(random), + offline: random.bool(0.3) + }), + missed_call: random => ({ + from: jidField(random), + call_id: generateString(random), + timestamp: generateNumber(random), + reason: random.pick(['offline', 'timeout', '']) + }), + call_ended_elsewhere: random => ({ + from: jidField(random), + call_id: generateString(random), + timestamp: generateNumber(random), + outcome: random.pick(['accepted', 'rejected', '']) + }), + dirty_state: random => ({ dirty_type: generateString(random), timestamp: generateNumber(random) }), + qr: random => ({ code: generateString(random) }), + // The bridge serialises `pair_success.{id,lid}` as strings even though the + // .d.ts types them as `Jid`, so both spellings have to be drawn here. + pair_success: random => ({ + id: random.bool(0.6) ? generateJid(random) : bridgeJid(random), + lid: random.bool(0.5) ? generateJid(random) : bridgeJid(random), + platform: generateString(random), + business_name: generateString(random) + }), + pairing_code: random => ({ code: generateString(random) }), + server_ack: random => ({ + id: generateString(random), + class: random.pick(['message', 'receipt', 'call', '']), + from: bridgeJid(random), + timestamp: generateNumber(random), + error: random.bool(0.3) ? generateString(random) : undefined + }), + raw_node: random => ({ tag: generateString(random), attrs: payload(random, 0), content: generateAnyValue(random) }), + mex_notification: random => ({ + op_name: generateString(random), + from: random.bool(0.7) ? bridgeJid(random) : undefined, + stanza_id: generateString(random), + offline: random.bool(), + payload: payload(random, 1) + }) +} + +// `contact_updated` is the same adapter under the older spelling. +SHAPED.contact_updated = SHAPED.contact_update! + +/** + * The payload an adapter reads for `type`, or a generic one where none is declared. + * + * `Object.hasOwn`, not a bracket lookup. `SHAPED` is an object literal and so + * inherits `Object.prototype`: `SHAPED['constructor']` is `Object` and returns + * the `Random` itself, `SHAPED['toString']` returns the string `'[object + * Undefined]'`, and `SHAPED['__proto__']` is not callable at all. Those three + * names are exactly what `generateUnknownBridgeEvent` emits as event types, and + * the registry already carries `bridge-adapter-prototype-chain-lookup` for this + * class of defect in the adapter — the generator must not reproduce it. + */ +export const shapedBridgePayload = (random: Random, type: string): Record => + (Object.hasOwn(SHAPED, type) ? SHAPED[type]! : payload)(random) + +export interface BridgeEventCase { + readonly type: string + readonly data: unknown +} + +/** A plausible-but-fuzzed event for a type the adapter table declares. */ +export const generateKnownBridgeEvent = (random: Random): BridgeEventCase => { + const type = random.pick(BRIDGE_EVENT_TYPES) + return { + type, + // Thunks, so only the selected branch is built. Passing values would construct + // every branch and draw from `random` for all of them, coupling the stream to + // branches that are never used. + data: random.weighted<() => unknown>([ + // The shape this type's adapter reads, so the mapping is reached and not + // just its guard clause. + [6, () => shapedBridgePayload(random, type)], + [3, () => payload(random)], + [1, () => undefined], + [1, () => null], + [1, () => generateString(random)], + [1, () => []], + [1, () => generateNumber(random)] + ])() + } +} + +/** An event the adapter table has never heard of: it must be dropped, not thrown on. */ +export const generateUnknownBridgeEvent = (random: Random): BridgeEventCase => ({ + type: random.weighted<() => string>([ + [3, () => generateString(random)], + [2, () => `${random.pick(BRIDGE_EVENT_TYPES)}_v2`], + [1, () => ''], + [1, () => '__proto__'], + [1, () => 'constructor'], + [1, () => 'toString'] + ])(), + data: payload(random) +}) + +export const generateBridgeEvent = (random: Random): BridgeEventCase => + random.bool(0.8) ? generateKnownBridgeEvent(random) : generateUnknownBridgeEvent(random) + +/** A sequence, so ordering and accumulation bugs have somewhere to appear. */ +export const generateBridgeEventSequence = (random: Random, max = 12): BridgeEventCase[] => + Array.from({ length: random.int(1, max) }, () => generateBridgeEvent(random)) + +/** + * Message-wire payloads, adapted by their own entry point. + * + * `MessageWireInfo` is camelCase — `senderAlt`, `isFromMe`, `isViewOnce`, + * `unavailableRequestId`. An earlier version of this generator used the + * snake_case spelling the *event* payloads use, so every one of those adapter + * branches read `undefined` and was never exercised at all. + */ +export const generateMessageWire = (random: Random): { info: Record; message: unknown } => ({ + info: { + id: generateString(random), + chat: generateMaybeJid(random), + sender: generateMaybeJid(random), + // Strings, not the bridge JID struct. `adaptBridgeMessageWire` reads + // `MessageWireInfo` with `asString`, so a struct here reads `undefined` and + // `participantAlt`/`remoteJidAlt` are never computed — measured: a struct + // yields `remoteJidAlt: undefined` where the string yields the JID. The + // struct form belongs on the *event* payloads, which go through + // `asJidString`; this is the other transport. + senderAlt: random.bool(0.4) ? generateJid(random) : undefined, + recipientAlt: random.bool(0.3) ? generateJid(random) : undefined, + isFromMe: random.bool(), + isGroup: random.bool(), + pushName: random.bool(0.7) ? generateString(random) : undefined, + timestamp: generateNumber(random), + isViewOnce: random.bool(0.3), + isOffline: random.bool(0.3), + unavailableRequestId: random.bool(0.25) ? generateString(random) : undefined, + edit: random.bool(0.25) ? random.pick(['1', '2', '7', '', 'x']) : undefined + }, + message: random.bool(0.8) ? { conversation: generateString(random) } : generateAnyValue(random) +}) diff --git a/src/__fuzz__/generators/jid.ts b/src/__fuzz__/generators/jid.ts new file mode 100644 index 00000000..5221533c Binary files /dev/null and b/src/__fuzz__/generators/jid.ts differ diff --git a/src/__fuzz__/generators/mutation.ts b/src/__fuzz__/generators/mutation.ts new file mode 100644 index 00000000..2213c36a --- /dev/null +++ b/src/__fuzz__/generators/mutation.ts @@ -0,0 +1,174 @@ +/** + * Byte-level mutators for the decoder robustness fuzzer. + * + * Random bytes are almost never valid protobuf, so a decoder rejects them in the + * first few instructions and nothing interesting is ever reached. Mutating a + * *valid* encoding is what gets past the outer checks and into the parsing loop — + * a truncated length prefix, a varint that never terminates, a field that claims + * to be longer than the buffer. + * + * Every mutator is deterministic given the same random stream, so any crash it + * finds replays from the seed. + */ + +import type { Random } from '../harness/random.ts' + +export interface Mutation { + readonly label: string + readonly bytes: Uint8Array +} + +const flipBit = (random: Random, bytes: Uint8Array): Uint8Array => { + if (bytes.length === 0) return bytes + const copy = bytes.slice() + const index = random.below(copy.length) + copy[index] = copy[index]! ^ (1 << random.below(8)) + return copy +} + +const replaceByte = (random: Random, bytes: Uint8Array): Uint8Array => { + if (bytes.length === 0) return bytes + const copy = bytes.slice() + copy[random.below(copy.length)] = random.below(256) + return copy +} + +const truncate = (random: Random, bytes: Uint8Array): Uint8Array => + bytes.slice(0, bytes.length === 0 ? 0 : random.below(bytes.length)) + +const dropByte = (random: Random, bytes: Uint8Array): Uint8Array => { + if (bytes.length === 0) return bytes + const index = random.below(bytes.length) + return Uint8Array.from([...bytes.slice(0, index), ...bytes.slice(index + 1)]) +} + +const insertByte = (random: Random, bytes: Uint8Array): Uint8Array => { + const index = random.below(bytes.length + 1) + return Uint8Array.from([...bytes.slice(0, index), random.below(256), ...bytes.slice(index)]) +} + +const duplicateRun = (random: Random, bytes: Uint8Array): Uint8Array => { + if (bytes.length === 0) return bytes + const start = random.below(bytes.length) + const end = start + 1 + random.below(Math.min(16, bytes.length - start)) + const run = bytes.slice(start, end) + return Uint8Array.from([...bytes, ...run]) +} + +/** Ten continuation bytes: a varint that never terminates. */ +const varintOverflow = (random: Random, bytes: Uint8Array): Uint8Array => { + const index = bytes.length === 0 ? 0 : random.below(bytes.length) + return Uint8Array.from([...bytes.slice(0, index), ...Array.from({ length: 10 }, () => 0xff), ...bytes.slice(index)]) +} + +/** A length-delimited field claiming far more bytes than the buffer holds. */ +const lyingLength = (random: Random, bytes: Uint8Array): Uint8Array => + Uint8Array.from([0x0a, random.pick([0x7f, 0xff]), ...bytes]) + +/** + * The tag bytes of each step in a message cycle, for the nesting bomb. + * + * `Message` reaches itself in two hops — `ephemeralMessage` (field 40) holds a + * `FutureProofMessage`, whose `message` (field 1) is a `Message` again — so a + * chain built along those two tags is a genuine recursive *message* descent. + */ +export type MessageCycle = readonly (readonly number[])[] + +/** Length-delimited framing for one wrap: tag, varint length, payload. */ +const wrap = (tag: readonly number[], payload: Uint8Array): Uint8Array => { + const length: number[] = [] + let size = payload.length + do { + length.push(size > 0x7f ? (size & 0x7f) | 0x80 : size & 0x7f) + size >>>= 7 + } while (size > 0) + return Uint8Array.from([...tag, ...length, ...payload]) +} + +/** + * A message nested `depth` times, following a real cycle in the schema when one + * is supplied. + * + * This is the shape that turns a recursive-descent parser into a stack overflow, + * and the one a hostile peer would send. + * + * The cycle matters. Wrapping everything in a hard-coded field 1 produces a + * deeply nested *length-delimited value* on any schema whose field 1 is a scalar + * — which exercises the length and bounds path at depth, but is one message deep, + * so a decoder with an unbounded recursive descent passes it at any advertised + * depth. Given a cycle, each wrap is another message the decoder must actually + * recurse into. + */ +const nestingBomb = (random: Random, bytes: Uint8Array, cycle?: MessageCycle): Uint8Array => { + let payload: Uint8Array = Uint8Array.from(bytes.slice(0, Math.min(bytes.length, 8))) + const depth = random.pick([16, 64, 256, 1_024, 4_096]) + const steps: MessageCycle = cycle && cycle.length > 0 ? cycle : [[0x0a]] + for (let level = 0; level < depth; level++) { + if (payload.length > 0x3f_ff) break + for (let step = steps.length - 1; step >= 0; step--) payload = wrap(steps[step]!, payload) + } + return payload +} + +/** + * Two valid encodings back to back. + * + * Protobuf defines this as a merge, not as corruption — last value wins for + * scalars, repeated fields concatenate, nested messages merge recursively. It is + * a legal input that most hand-rolled parsers get wrong. + */ +const concatenate = (bytes: Uint8Array, other: Uint8Array): Uint8Array => Uint8Array.from([...bytes, ...other]) + +export const MUTATORS = [ + 'flip-bit', + 'replace-byte', + 'truncate', + 'drop-byte', + 'insert-byte', + 'duplicate-run', + 'varint-overflow', + 'lying-length', + 'nesting-bomb', + 'concatenate', + 'empty', + 'random' +] as const + +export type MutatorName = (typeof MUTATORS)[number] + +export const mutate = ( + random: Random, + bytes: Uint8Array, + other: Uint8Array, + mutator: MutatorName, + cycle?: MessageCycle +): Mutation => { + switch (mutator) { + case 'flip-bit': + return { label: mutator, bytes: flipBit(random, bytes) } + case 'replace-byte': + return { label: mutator, bytes: replaceByte(random, bytes) } + case 'truncate': + return { label: mutator, bytes: truncate(random, bytes) } + case 'drop-byte': + return { label: mutator, bytes: dropByte(random, bytes) } + case 'insert-byte': + return { label: mutator, bytes: insertByte(random, bytes) } + case 'duplicate-run': + return { label: mutator, bytes: duplicateRun(random, bytes) } + case 'varint-overflow': + return { label: mutator, bytes: varintOverflow(random, bytes) } + case 'lying-length': + return { label: mutator, bytes: lyingLength(random, bytes) } + case 'nesting-bomb': + return { label: mutator, bytes: nestingBomb(random, bytes, cycle) } + case 'concatenate': + return { label: mutator, bytes: concatenate(bytes, other) } + case 'empty': + return { label: mutator, bytes: new Uint8Array(0) } + case 'random': + return { label: mutator, bytes: random.bytes(random.int(0, 128)) } + default: + return { label: 'identity', bytes } + } +} diff --git a/src/__fuzz__/generators/proto.ts b/src/__fuzz__/generators/proto.ts new file mode 100644 index 00000000..b24bdb8a --- /dev/null +++ b/src/__fuzz__/generators/proto.ts @@ -0,0 +1,330 @@ +/** + * Schema-driven protobuf message generator. + * + * `src/WAProto/compatibility-schema.ts` is generated from the upstream Baileys + * protos and lists every message, every field, its kind, and the flags that + * decide how it is encoded. That file is already the single source of truth for + * the declaration audits — here it is used as a *grammar*, so the codec fuzzers + * can build arbitrary valid messages for any of the several hundred types + * without a line of hand-written fixture. + * + * The knobs exist because the interesting inputs are not uniform. A generator + * that only ever emits well-formed values never sets a `proto3Optional` field to + * its default, never puts two members in a `oneof`, and never sends 2^63-1 — + * which is where a Rust encoder and protobufjs are most likely to part ways. + */ + +import { + PROTO_ENUM_SCHEMAS, + PROTO_FIELD_FLAG, + PROTO_FIELD_KIND, + PROTO_MESSAGE_SCHEMAS, + type ProtoFieldSchema, + type ProtoMessageSchema +} from '../../WAProto/compatibility-schema.ts' +import type { Random } from '../harness/random.ts' + +const messageSchemas = PROTO_MESSAGE_SCHEMAS as readonly ProtoMessageSchema[] +const enumSchemas = PROTO_ENUM_SCHEMAS as readonly (readonly [string, readonly (string | number)[]])[] + +const schemaIndexByPath = new Map(messageSchemas.map((entry, index) => [entry[0], index] as const)) + +export const PROTO_PATHS: readonly string[] = messageSchemas.map(entry => entry[0]) + +/** Message types worth over-sampling: they carry the traffic that actually flows. */ +export const HOT_PROTO_PATHS: readonly string[] = [ + 'Message', + 'WebMessageInfo', + 'MessageKey', + 'ContextInfo', + 'Message.ExtendedTextMessage', + 'Message.ImageMessage', + 'Message.VideoMessage', + 'Message.AudioMessage', + 'Message.DocumentMessage', + 'Message.ProtocolMessage', + 'Message.ReactionMessage', + 'Message.PollCreationMessage', + 'Message.PollUpdateMessage', + 'MessageContextInfo', + 'SyncActionValue', + 'HistorySync', + 'Conversation' +].filter(path => schemaIndexByPath.has(path)) + +export const fieldsOfPath = (path: string): readonly ProtoFieldSchema[] => { + const index = schemaIndexByPath.get(path) + return index === undefined ? [] : messageSchemas[index]![1] +} + +const isRepeated = (field: ProtoFieldSchema) => (field[3] & PROTO_FIELD_FLAG.repeated) !== 0 +const isMap = (field: ProtoFieldSchema) => (field[3] & PROTO_FIELD_FLAG.map) !== 0 +export const isProto3Optional = (field: ProtoFieldSchema) => (field[3] & PROTO_FIELD_FLAG.proto3Optional) !== 0 + +/** Valid members of an enum, plus values outside it — decoders disagree on those. */ +const enumValues = (reference: number): number[] => { + const entries = enumSchemas[reference]?.[1] ?? [] + const values: number[] = [] + for (let index = 0; index + 1 < entries.length; index += 2) { + const value = entries[index + 1] + if (typeof value === 'number') values.push(value) + } + return values +} + +export interface ProtoGenerateOptions { + /** How deep nested messages may nest. */ + readonly maxDepth?: number + /** Chance that any given field is populated at all. */ + readonly fieldProbability?: number + /** + * Chance that a populated field carries its proto3 default (`0`, `''`, + * `false`, empty bytes). Presence semantics are the single most likely place + * for two encoders to disagree, so this is a first-class knob. + */ + readonly defaultBias?: number + /** Emit values at the edges of the 32- and 64-bit ranges. */ + readonly extremeIntegers?: boolean + /** Allow unpaired UTF-16 surrogates in string fields. */ + readonly surrogates?: boolean + /** Populate repeated fields. */ + readonly allowRepeated?: boolean + /** Populate more than one member of the same `oneof`. */ + readonly multiOneof?: boolean + /** Emit enum values outside the declared set. */ + readonly outOfRangeEnums?: boolean +} + +const DEFAULTS: Required = { + maxDepth: 3, + fieldProbability: 0.45, + defaultBias: 0.15, + extremeIntegers: true, + surrogates: false, + allowRepeated: true, + multiOneof: false, + outOfRangeEnums: false +} + +const SAFE_STRINGS = ['', 'a', 'hello', '0', 'áé', '👍', 'x'.repeat(64), '\n\t', '{"json":true}', 'null'] as const +const SURROGATE_STRINGS = ['\uD800', '\uDFFF', 'a\uD800b', '\uD83D', '\uDE00'] as const + +const signed32 = [0, 1, -1, 127, -128, 32_767, -32_768, 2_147_483_647, -2_147_483_648] as const +const unsigned32 = [0, 1, 127, 255, 65_535, 2_147_483_647, 2_147_483_648, 4_294_967_295] as const +/** + * The empty string is in both 64-bit pools on purpose, and in neither 32-bit one. + * + * protobufjs routes a 64-bit field through `Long.fromString`, which throws on + * `''`; the bridge coerces it to 0. A 32-bit field is coerced to 0 by both, so + * putting `''` there would generate inputs that can never diverge — and leaving + * it out of the 64-bit pools left `proto-empty-string-for-numeric-field` with no + * reachable reproducer at all, which would have made the registry entry look + * stale on the first complete nightly run. + */ +const signed64 = [ + '', + '0', + '1', + '-1', + '9007199254740991', + '9007199254740993', + '9223372036854775807', + '-9223372036854775808' +] as const +const unsigned64 = ['', '0', '1', '9007199254740991', '9007199254740993', '18446744073709551615'] as const + +const scalarValue = (random: Random, field: ProtoFieldSchema, options: Required): unknown => { + const wantsDefault = random.bool(options.defaultBias) + + switch (field[1]) { + case PROTO_FIELD_KIND.string: { + if (wantsDefault) return '' + if (options.surrogates && random.bool(0.3)) return random.pick(SURROGATE_STRINGS) + return random.pick(SAFE_STRINGS) + } + case PROTO_FIELD_KIND.bool: + return !wantsDefault + case PROTO_FIELD_KIND.bytes: + return wantsDefault ? new Uint8Array(0) : random.bytes(random.pick([1, 4, 16, 32, 100])) + case PROTO_FIELD_KIND.float: + return wantsDefault ? 0 : random.pick([1.5, -1.5, 0.1, 3.402_823_5e38, 1.175_494_4e-38]) + case PROTO_FIELD_KIND.enum: { + const values = enumValues(field[2]) + if (values.length === 0) return 0 + if (options.outOfRangeEnums && random.bool(0.3)) return random.pick([9_999, -1, 2_147_483_647]) + if (wantsDefault) return values.includes(0) ? 0 : values[0]! + return random.pick(values) + } + case PROTO_FIELD_KIND.signed32: + return wantsDefault ? 0 : options.extremeIntegers ? random.pick(signed32) : random.int(0, 1_000) + case PROTO_FIELD_KIND.unsigned32: + return wantsDefault ? 0 : options.extremeIntegers ? random.pick(unsigned32) : random.int(0, 1_000) + case PROTO_FIELD_KIND.signed64: + return wantsDefault ? 0 : options.extremeIntegers ? random.pick(signed64) : random.int(0, 1_000) + case PROTO_FIELD_KIND.unsigned64: + return wantsDefault ? 0 : options.extremeIntegers ? random.pick(unsigned64) : random.int(0, 1_000) + default: + return 0 + } +} + +const fieldValue = ( + random: Random, + field: ProtoFieldSchema, + depth: number, + options: Required +): unknown => { + if (field[1] === PROTO_FIELD_KIND.message) { + if (depth <= 0) return undefined + const nested = messageSchemas[field[2]] + if (!nested) return undefined + return generateProtoObject(random, nested[0], depth - 1, options) + } + return scalarValue(random, field, options) +} + +/** + * Builds a plain object for `path`, the same shape a caller would hand to + * `encodeProto` or to protobufjs. + */ +export const generateProtoObject = ( + random: Random, + path: string, + depth: number, + overrides: ProtoGenerateOptions = {} +): Record => { + const options = { ...DEFAULTS, ...overrides } + const fields = fieldsOfPath(path) + const message: Record = {} + const usedOneofs = new Set() + + for (const field of fields) { + // Map fields are not generated, and nothing else in this suite covers them: + // the wire fuzzer builds its messages here too, and the field-name sweep + // skips maps as well. The compact schema records the value kind but not the + // key type, so a faithful shape cannot be derived from it — closing this gap + // needs key metadata in `waproto-facade.ts` first. Stated rather than + // implied, because an unstated gap reads as coverage. + if (isMap(field)) continue + if (!random.bool(options.fieldProbability)) continue + + const oneof = field[4] + if (oneof && usedOneofs.has(oneof) && !options.multiOneof) continue + + const value = fieldValue(random, field, depth, options) + if (value === undefined) continue + + if (isRepeated(field)) { + if (!options.allowRepeated) continue + const count = random.pick([0, 1, 1, 2, 3]) + const items: unknown[] = [] + for (let index = 0; index < count; index++) { + const item = fieldValue(random, field, depth, options) + if (item !== undefined) items.push(item) + } + message[field[0]] = items + } else { + message[field[0]] = value + } + + if (oneof) usedOneofs.add(oneof) + } + + return message +} + +/** A message type to fuzz, biased toward the ones real traffic is made of. */ +export const pickProtoPath = (random: Random): string => + random.bool(0.5) && HOT_PROTO_PATHS.length > 0 ? random.pick(HOT_PROTO_PATHS) : random.pick(PROTO_PATHS) + +export interface ProtoCase { + readonly path: string + readonly message: Record +} + +export const generateProtoCase = (random: Random, overrides: ProtoGenerateOptions = {}): ProtoCase => { + const path = pickProtoPath(random) + return { path, message: generateProtoObject(random, path, overrides.maxDepth ?? DEFAULTS.maxDepth, overrides) } +} + +/** Every field of `path` that carries explicit presence, for the presence fuzzer. */ +export const proto3OptionalFields = (path: string): readonly ProtoFieldSchema[] => + fieldsOfPath(path).filter(field => isProto3Optional(field) && !isMap(field) && !isRepeated(field)) + +/** + * Every message type `path` can reach through its fields, transitively. + * + * Used to tell "the bridge dropped a field" apart from "the bridge does not + * implement the type that field holds". Cycles are common in these protos + * (ContextInfo quotes a Message which carries a ContextInfo), so the walk is + * guarded by the visited set rather than by depth. + */ +export const reachableTypes = (path: string): ReadonlySet => { + const seen = new Set() + const queue = [path] + while (queue.length > 0) { + const current = queue.pop()! + if (seen.has(current)) continue + seen.add(current) + for (const field of fieldsOfPath(current)) { + if (field[1] !== PROTO_FIELD_KIND.message) continue + const nested = messageSchemas[field[2]] + if (nested && !seen.has(nested[0])) queue.push(nested[0]) + } + } + seen.delete(path) + return seen +} + +/** The message type a message-kind field holds, or undefined for a scalar. */ +export const messagePathOfField = (field: ProtoFieldSchema): string | undefined => + field[1] === PROTO_FIELD_KIND.message ? messageSchemas[field[2]]?.[0] : undefined + +/** + * A predicate over a decoded object's property path: is this field declared as text? + * + * Needed because `normalise`'s scalar coercion folds a decimal string into a + * bigint, which is right for a 64-bit integer (protobufjs renders it as a + * string, the bridge as a bigint) and wrong for a declared `string` field — with + * it, `{ text: "0" }` and `{ text: 0 }` compare equal and a decoder that turned + * a numeric-looking string into a number would be invisible. The schema is what + * tells the two apart, so the comparison has to carry it. + * + * `bytes` counts as text here for the same reason: the two sides spell it + * differently but neither spells it as a number. + */ +export const textFieldPredicate = (rootPath: string): ((propertyPath: readonly string[]) => boolean) => { + const fieldNamed = (path: string, name: string): ProtoFieldSchema | undefined => + fieldsOfPath(path).find(field => field[0] === name) + + // A name the schema does not declare answers `false`: nothing is known about + // it, and the bridge's own spellings live there — it writes `deviceAgentId` + // for the declared `deviceAgentID` — so answering `true` would treat one side + // of a known rename as text and the other as a number, manufacturing a + // difference out of the rename. Both sides being coerced the same way is what + // keeps the rename entry able to see past it. + return propertyPath => { + let current: string | undefined = rootPath + for (const [index, name] of propertyPath.entries()) { + if (current === undefined) return false + const field: ProtoFieldSchema | undefined = fieldNamed(current, name) + if (!field) return false + if (index === propertyPath.length - 1) { + return field[1] === PROTO_FIELD_KIND.string || field[1] === PROTO_FIELD_KIND.bytes + } + current = messagePathOfField(field) + } + return false + } +} + +/** The `oneof` groups declared on `path`, for the oneof fuzzer. */ +export const oneofGroups = (path: string): Map => { + const groups = new Map() + for (const field of fieldsOfPath(path)) { + const oneof = field[4] + if (!oneof) continue + groups.set(oneof, [...(groups.get(oneof) ?? []), field]) + } + return groups +} diff --git a/src/__fuzz__/generators/values.ts b/src/__fuzz__/generators/values.ts new file mode 100644 index 00000000..60908f87 --- /dev/null +++ b/src/__fuzz__/generators/values.ts @@ -0,0 +1,152 @@ +/** + * Hostile scalar and container generators shared by several fuzzers. + * + * The numeric list is the important one. Most parity bugs between a Rust + * implementation and a JavaScript one live at the boundaries where a JS number + * stops being exact (2^53), where a signed 32-bit value wraps, and where + * protobuf's 64-bit integers stop fitting in a `number` at all — so those values + * are enumerated rather than left to chance. + */ + +import type { Random } from '../harness/random.ts' + +/** Integer boundaries worth hitting on purpose, as numbers. */ +export const BOUNDARY_NUMBERS = [ + 0, + 1, + -1, + 2, + 7, + 127, + 128, + 255, + 256, + 32_767, + 32_768, + 65_535, + 65_536, + 2_147_483_647, + 2_147_483_648, + -2_147_483_648, + -2_147_483_649, + 4_294_967_295, + 4_294_967_296, + Number.MAX_SAFE_INTEGER, + Number.MAX_SAFE_INTEGER - 1, + -Number.MAX_SAFE_INTEGER +] as const + +/** Values that are numbers to JavaScript but not integers to a protobuf encoder. */ +export const HOSTILE_NUMBERS = [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, + -0, + 0.5, + 1.5, + -1.5, + 1e21, + 1e-7, + // Written as an expression: the literal 9007199254740993 is not representable + // as a double, and the lint rule that says so is right — the point is to hand + // the encoders a value just past the safe-integer boundary. + Number.MAX_SAFE_INTEGER + 2 +] as const + +/** 64-bit boundaries, as the decimal strings untyped callers actually pass. */ +export const BIG_INTEGER_STRINGS = [ + '0', + '1', + '-1', + '9007199254740991', + '9007199254740992', + '9007199254740993', + '9223372036854775807', + '-9223372036854775808', + '18446744073709551615', + '18446744073709551616' +] as const + +export const HOSTILE_STRINGS = [ + '', + ' ', + 'a', + '0', + 'null', + 'undefined', + '{}', + '[]', + '\0', + '\n', + '\t\r\n', + '"', + '\\', + '👨‍👩‍👧‍👦', + 'é', + '\uD800', // lone high surrogate: not valid UTF-8, and the encoders disagree on what to do + '\uDFFF', // lone low surrogate + '', + 'x'.repeat(1_024), + '../../etc/passwd', + '%s%n', + '𝕬𝖑𝖕𝖍𝖆' +] as const + +export const generateNumber = (random: Random): number => + random.weighted<() => number>([ + [5, () => random.pick(BOUNDARY_NUMBERS)], + [2, () => random.pick(HOSTILE_NUMBERS)], + [3, () => random.int(-1_000, 1_000)], + [1, () => random.next() * 1e12] + ])() + +export const generateString = (random: Random): string => + random.weighted<() => string>([ + [5, () => random.pick(HOSTILE_STRINGS)], + [3, () => Buffer.from(random.bytes(random.int(0, 24))).toString('hex')], + [2, () => String.fromCodePoint(...Array.from({ length: random.int(0, 12) }, () => random.int(1, 0x10_ff_ff)))] + ])() + +export const generateBytes = (random: Random): Uint8Array => + random.weighted<() => Uint8Array>([ + [4, () => random.bytes(random.int(0, 64))], + [2, () => new Uint8Array(0)], + [2, () => random.bytes(random.pick([1, 15, 16, 17, 31, 32, 33, 63, 64, 65]))], + [1, () => new Uint8Array(random.int(0, 128))] + ])() + +/** Anything at all — for the helpers whose declared parameter type is not the contract. */ +export const generateAnyValue = (random: Random, depth = 0): unknown => + random.weighted<() => unknown>([ + [4, () => generateString(random)], + [4, () => generateNumber(random)], + [2, () => random.bool()], + [2, () => undefined], + [2, () => null], + [2, () => generateBytes(random)], + [1, () => random.pick(BIG_INTEGER_STRINGS)], + [depth < 2 ? 2 : 0, () => Array.from({ length: random.int(0, 4) }, () => generateAnyValue(random, depth + 1))], + [ + depth < 2 ? 2 : 0, + () => { + const out: Record = {} + // Drawn once: in the loop condition it would be re-rolled every pass, + // turning the length into a repeated coin flip and consuming an extra + // draw per iteration that shifts every later value for the same seed. + const keyCount = random.int(0, 4) + for (let index = 0; index < keyCount; index++) { + const key = random.pick(['a', 'b', 'id', 'key', 'type', 'value', '0', '__proto__', 'constructor']) + // `out.__proto__ = x` swaps the prototype; `defineProperty` keeps it + // an own data property. The latter is both the more hostile input and + // the shape `JSON.parse` actually produces. + Object.defineProperty(out, key, { + value: generateAnyValue(random, depth + 1), + enumerable: true, + writable: true, + configurable: true + }) + } + return out + } + ] + ])() diff --git a/src/__fuzz__/harness/__tests__/harness.test.ts b/src/__fuzz__/harness/__tests__/harness.test.ts new file mode 100644 index 00000000..25178dee --- /dev/null +++ b/src/__fuzz__/harness/__tests__/harness.test.ts @@ -0,0 +1,628 @@ +/** + * The fuzz harness tests itself. + * + * A broken shrinker does not fail loudly — it reports a minimal case that is not + * minimal, or worse, one that reproduces a different bug than the one found. A + * non-deterministic PRNG makes every replay hint in a failure report a lie. Both + * failure modes are silent in the fuzzers themselves, so they are pinned here. + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { applyAllowlist, staleEntries, type Divergence, type KnownDivergence } from '../divergence.ts' +import { corpusSlug } from '../corpus.ts' +import { makeRandom } from '../random.ts' +import { shrink } from '../shrink.ts' +import { + canonicalWire, + differsOnlyByPacking, + isWireSubset, + sameWireContent, + sameWireOrdering, + type SchemaContext +} from '../wire.ts' + +describe('fuzz harness — deterministic randomness', () => { + it('replays an identical stream for an identical seed', () => { + const draw = () => { + const random = makeRandom('seed-a') + return [random.next(), random.int(0, 1000), random.pick([1, 2, 3, 4]), [...random.bytes(8)]] + } + assert.deepEqual(draw(), draw()) + }) + + it('separates streams by seed and by fork label', () => { + const a = makeRandom('seed-a') + const b = makeRandom('seed-b') + assert.notDeepEqual([a.next(), a.next(), a.next()], [b.next(), b.next(), b.next()]) + + const parent = makeRandom('seed-a') + const left = parent.fork('left') + const right = parent.fork('right') + assert.notEqual(left.seed, right.seed) + assert.notDeepEqual([left.next(), left.next()], [right.next(), right.next()]) + }) + + it('keeps every generator inside its declared bounds', () => { + const random = makeRandom('bounds') + for (let index = 0; index < 5_000; index++) { + const value = random.int(-5, 5) + assert.ok(value >= -5 && value <= 5 && Number.isInteger(value), `int out of range: ${value}`) + assert.ok(random.below(3) < 3) + assert.equal(random.below(0), 0) + } + assert.equal(random.int(7, 7), 7) + }) + + it('honours relative weights', () => { + const random = makeRandom('weights') + let rare = 0 + for (let index = 0; index < 4_000; index++) { + if ( + random.weighted([ + [1, 'rare'], + [99, 'common'] + ]) === 'rare' + ) + rare++ + } + assert.ok(rare > 5 && rare < 200, `expected a roughly 1% tail, saw ${rare}/4000`) + }) + + it('never picks an entry whose weight is not positive', () => { + const random = makeRandom('zero-weight') + for (let index = 0; index < 500; index++) { + assert.equal( + random.weighted([ + [0, 'never'], + [1, 'always'] + ]), + 'always' + ) + } + }) +}) + +describe('fuzz harness — shrinking', () => { + it('reduces an object to the single field that reproduces', async () => { + const input = { a: 1, b: 2, culprit: 99, d: 'noise', e: [1, 2, 3], f: { g: true } } + const minimal = await shrink(input, candidate => (candidate as { culprit?: number }).culprit === 99) + assert.deepEqual(minimal, { culprit: 99 }) + }) + + it('reduces an array to the offending element', async () => { + const input = [0, 0, 0, 7, 0, 0] + const minimal = await shrink(input, candidate => candidate.includes(7)) + assert.deepEqual(minimal, [7]) + }) + + it('reaches into nested structures', async () => { + const input = { outer: { noise: 'x'.repeat(64), inner: { keep: 5, drop: 'y' } }, sibling: [1, 2, 3] } + const minimal = await shrink(input, candidate => { + const nested = candidate as { outer?: { inner?: { keep?: number } } } + return nested.outer?.inner?.keep === 5 + }) + assert.deepEqual(minimal, { outer: { inner: { keep: 5 } } }) + }) + + it('shrinks strings and byte arrays toward empty', async () => { + const long = await shrink('abcdefghijklmnop', candidate => candidate.length > 0) + assert.equal(long.length, 1) + + const bytes = await shrink(new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]), candidate => candidate.length > 2) + assert.ok(bytes.length >= 3 && bytes.length < 8, `expected a shorter but still-failing slice, got ${bytes.length}`) + }) + + it('supports async predicates', async () => { + const minimal = await shrink({ keep: 1, drop: 2 }, async candidate => { + await Promise.resolve() + return (candidate as { keep?: number }).keep === 1 + }) + assert.deepEqual(minimal, { keep: 1 }) + }) + + it('returns the original when nothing simpler reproduces', async () => { + const input = { only: 'value' } + assert.deepEqual(await shrink(input, candidate => JSON.stringify(candidate) === JSON.stringify(input)), input) + }) + + it('treats a predicate that throws as "does not reproduce"', async () => { + const minimal = await shrink({ a: 1, b: 2 }, candidate => { + const record = candidate as Record + if (Object.keys(record).length === 0) throw new Error('predicate cannot handle empty') + return record.a === 1 + }) + assert.deepEqual(minimal, { a: 1 }) + }) + + it('stays inside its evaluation budget', async () => { + let evaluations = 0 + await shrink( + Array.from({ length: 50 }, (_value, index) => [`k${index}`, index]).reduce>( + (accumulator, [key, value]) => ({ ...accumulator, [String(key)]: Number(value) }), + {} + ), + () => { + evaluations++ + return true + }, + { maxEvaluations: 25 } + ) + assert.ok(evaluations <= 25, `budget ignored: ${evaluations} evaluations`) + }) + + /** + * The evaluation count bounds checks, not time. + * + * A check that reproduces a slow input costs whatever that input costs, so the + * deep budget's 1200 evaluations against a one-second reproducer is twenty + * minutes of shrinking — past the target's own deadline and long enough to hit + * the parent test timeout, which loses the report entirely. That is strictly + * worse than reporting a large input, so the clock gets its own bound. + */ + it('stops shrinking at its deadline, keeping what it has found', async () => { + const input: Record = { culprit: 99 } + for (let index = 0; index < 40; index++) input[`noise${index}`] = 'x'.repeat(32) + const slow = async (candidate: unknown) => { + const until = performance.now() + 20 + while (performance.now() < until) { + // Busy, not idle: a real reproducer burns CPU inside the check, so a + // timer-based stall would not exercise the same path. + } + return (candidate as { culprit?: number })?.culprit === 99 + } + + const started = performance.now() + const minimised = (await shrink(input, slow, { maxEvaluations: 1_200, deadline: started + 200 })) as Record< + string, + unknown + > + const elapsed = performance.now() - started + + // Generous, so a slow machine cannot flake it: the point is that it is + // bounded at all, against an unbounded run measured at ~900ms. + assert.ok(elapsed < 2_000, `deadline ignored: shrinking ran for ${Math.round(elapsed)}ms`) + // And what comes back is a real partial result, not a bail-out: it still + // reproduces, which is what makes stopping early safe. + assert.equal(minimised.culprit, 99, 'the partly-minimised input must still reproduce') + }) +}) + +describe('fuzz harness — known-divergence allowlist', () => { + const divergence = (target: string, detail?: string): Divergence => ({ + target, + input: 'in', + local: 'a', + upstream: 'b', + detail + }) + + const future = '2999-01-01' + const past = '2000-01-01' + + it('excuses only what an entry actually covers', () => { + const registry: KnownDivergence[] = [ + { id: 'covered', target: 'jid:one', status: 'intended', reason: 'intended', review: future } + ] + const outcome = applyAllowlist([divergence('jid:one'), divergence('jid:two')], new Date(), registry) + assert.deepEqual( + outcome.unexcused.map(item => item.target), + ['jid:two'] + ) + assert.deepEqual(outcome.used, ['covered']) + }) + + it('matches a family of targets by pattern and narrows with a predicate', () => { + const registry: KnownDivergence[] = [ + { + id: 'family', + target: /^proto:/u, + status: 'intended', + reason: 'intended', + review: future, + when: item => item.detail === 'presence' + } + ] + const outcome = applyAllowlist( + [divergence('proto:a', 'presence'), divergence('proto:b', 'ordering'), divergence('jid:x', 'presence')], + new Date(), + registry + ) + assert.deepEqual( + outcome.unexcused.map(item => item.target), + ['proto:b', 'jid:x'] + ) + }) + + it('flags entries whose review date has passed', () => { + const registry: KnownDivergence[] = [ + { id: 'stale', target: 'jid:one', status: 'intended', reason: 'intended', review: past }, + { id: 'fresh', target: 'jid:two', status: 'intended', reason: 'intended', review: future } + ] + const outcome = applyAllowlist([], new Date(), registry) + assert.deepEqual( + outcome.expired.map(entry => entry.id), + ['stale'] + ) + }) + + it('reports entries that excused nothing', () => { + const registry: KnownDivergence[] = [ + { id: 'used', target: 'jid:one', status: 'intended', reason: 'intended', review: future }, + { id: 'unused', target: 'jid:nine', status: 'open', reason: 'intended', review: future } + ] + const outcome = applyAllowlist([divergence('jid:one')], new Date(), registry) + assert.deepEqual( + staleEntries(outcome.used, registry).map(entry => entry.id), + ['unused'] + ) + }) + + it('separates entries that are still open from the ones that are intended', () => { + const registry: KnownDivergence[] = [ + { id: 'deliberate', target: 'jid:one', status: 'intended', reason: 'on purpose', review: future }, + { id: 'untriaged', target: 'jid:two', status: 'open', reason: 'still a bug', review: future } + ] + const outcome = applyAllowlist([divergence('jid:one'), divergence('jid:two')], new Date(), registry) + assert.deepEqual(outcome.unexcused, [], 'both are excused so the run stays green') + assert.deepEqual(outcome.openHits, ['untriaged'], 'but the open one is surfaced on every run') + }) + + // The cleanMessage entry is the one with the most reach: it matches a family + // of targets and its predicate walks the whole argument tuple. It was widened + // from `remoteJid` to `participant` after the fuzzer found the same rewrite on + // the second field, and a widening is exactly the change that quietly starts + // excusing things it should not — so the near-misses are pinned here. + // The two int64 entries are the newest and the least settled, and one of them + // masks values before deferring to a sibling entry — which is the construction + // most likely to widen quietly. Pinned the same way as the cleanMessage one. + it('excuses the int64 conversion only where a number an int64 cannot carry is involved', async () => { + const { KNOWN_DIVERGENCES } = await import('../divergence.ts') + const registry = KNOWN_DIVERGENCES.filter(entry => entry.id === 'forward-message-content-int64-truncation') + assert.equal(registry.length, 1, 'the entry under test is still in the registry') + + const excused = (input: unknown, local: unknown, upstream: unknown): boolean => + applyAllowlist( + [{ target: 'pure:generateForwardMessageContent', input, local, upstream }], + new Date('2026-01-01'), + registry + ).unexcused.length === 0 + + const withTimestamp = (value: unknown) => [ + { key: {}, message: { pollUpdateMessage: { senderTimestampMs: value } } } + ] + + // The measured shapes: a fraction truncated, and a null defaulted to zero. + assert.ok( + excused( + withTimestamp(-1.5), + { pollUpdateMessage: { senderTimestampMs: -1.5 } }, + { + pollUpdateMessage: { senderTimestampMs: '-1' } + } + ), + 'the measured truncation is the entry’s subject' + ) + // And the same message also dropping a key, which is what forced the mask to + // compose with the key-presence entry rather than demand equal key sets. + assert.ok( + excused( + withTimestamp(-1.5), + { pollUpdateMessage: { senderTimestampMs: -1.5, pollCreationMessageKey: { remoteJidAlt: '' } } }, + { pollUpdateMessage: { senderTimestampMs: '-1', pollCreationMessageKey: {} } } + ), + 'a truncation alongside a dropped key is still the two documented differences' + ) + + // Near-misses. + assert.ok( + !excused( + withTimestamp(7), + { pollUpdateMessage: { senderTimestampMs: 7 } }, + { + pollUpdateMessage: { senderTimestampMs: '8' } + } + ), + 'a changed integer is a value bug, not a conversion' + ) + assert.ok( + !excused( + withTimestamp(-1.5), + { pollUpdateMessage: { senderTimestampMs: -1.5, text: 'hello' } }, + { pollUpdateMessage: { senderTimestampMs: '-1', text: 'HELLO' } } + ), + 'a changed string must not ride along with the truncation' + ) + assert.ok( + !excused( + withTimestamp(7), + { pollUpdateMessage: { senderTimestampMs: 7, extra: 1 } }, + { pollUpdateMessage: { senderTimestampMs: 7 } } + ), + 'without an inexact number there is nothing for this entry to excuse' + ) + }) + + // The merge-precedence entry pairs observations by kind and identity rather + // than by position, so that it composes with the release-order entry. That is + // exactly the construction that can quietly start excusing a lost event, so + // the boundaries are pinned. + it('excuses the buffer merge precedence only for the two kinds it names', async () => { + const { KNOWN_DIVERGENCES } = await import('../divergence.ts') + const registry = KNOWN_DIVERGENCES.filter(entry => entry.id === 'event-buffer-merge-precedence') + assert.equal(registry.length, 1, 'the entry under test is still in the registry') + + const excused = (local: unknown, upstream: unknown): boolean => + applyAllowlist( + [{ target: 'buffer:differential', input: 'in', local, upstream }], + new Date('2026-01-01'), + registry + ).unexcused.length === 0 + + const upsert = (name: string) => ({ released: 'contacts.upsert', data: [{ id: 'a@s.whatsapp.net', name }] }) + const group = (subject: string) => ({ released: 'groups.update', data: [{ id: 'g@g.us', subject }] }) + const receipt = { released: 'message-receipt.update', data: [{ key: { id: 'B2' } }] } + + assert.ok(excused([upsert('first')], [upsert('second')]), 'the measured contacts precedence is the subject') + assert.ok(excused([group('second')], [group('first')]), 'so is the groups one, which runs the other way round') + // The case that forced the pairing: a field difference *and* a reordering. + assert.ok( + excused([upsert('first'), receipt], [receipt, upsert('second')]), + 'a field difference alongside a reordering is the two documented entries together' + ) + + // Near-misses. + assert.ok(!excused([upsert('first'), receipt], [upsert('second')]), 'a dropped event is not this') + assert.ok( + !excused( + [{ released: 'chats.update', data: [{ id: 'a', name: 'first' }] }], + [{ released: 'chats.update', data: [{ id: 'a', name: 'second' }] }] + ), + 'chats.update was measured to agree, so a difference there is unexplained' + ) + assert.ok( + !excused( + [upsert('same'), group('x')], + [upsert('same'), { released: 'groups.update', data: [{ id: 'OTHER@g.us', subject: 'x' }] }] + ), + 'a changed id is a different entity, not a merge precedence' + ) + assert.ok( + !excused([upsert('same'), receipt], [receipt, upsert('same')]), + 'a pure reordering belongs to the sibling entry' + ) + }) + + it('excuses the cleanMessage JID rewrite only in the shape it documents', async () => { + const { KNOWN_DIVERGENCES } = await import('../divergence.ts') + const registry = KNOWN_DIVERGENCES.filter(entry => entry.id === 'clean-message-empty-user-jid-server') + assert.equal(registry.length, 1, 'the entry under test is still in the registry') + + const excused = (local: unknown, upstream: unknown): boolean => + applyAllowlist( + [{ target: 'pure:cleanMessage#mutation', input: 'in', local, upstream }], + new Date('2026-01-01'), + registry + ).unexcused.length === 0 + + // The documented shape, on each field, including the extra `remoteJid: ''` + // upstream materialises alongside it. + assert.ok( + excused( + [{ key: { participant: '@hosted' } }, '', ''], + [{ key: { participant: '@s.whatsapp.net', remoteJid: '' } }, '', ''] + ), + 'the measured participant rewrite is the entry’s subject' + ) + assert.ok( + excused([{ key: { remoteJid: '@hosted.lid' } }], [{ key: { remoteJid: '@lid' } }]), + 'so is the remoteJid rewrite it was originally written for' + ) + + // Near-misses. Each one differs from the shape above by a single property. + assert.ok( + !excused([{ key: { participant: 'a@x' } }], [{ key: { participant: 'b@x' } }]), + 'a rewrite between two non-empty users is a different defect' + ) + assert.ok( + !excused( + [{ key: { participant: '@hosted' }, message: { conversation: 'hi' } }], + [{ key: { participant: '@s.whatsapp.net' }, message: { conversation: 'HI' } }] + ), + 'a changed message body must not ride along with the rewrite' + ) + assert.ok( + !excused([{ key: { participant: '@hosted' } }], [{ key: { remoteJid: '@s.whatsapp.net' } }]), + 'the JID has to move in the same field on both sides' + ) + assert.ok( + !excused([{ key: { participant: '@hosted', remoteJid: '@hosted' } }], [{ key: { participant: '@hosted' } }]), + 'a JID baileyrs writes and upstream does not is not this entry' + ) + assert.ok( + !excused([{ key: { participant: '@hosted' } }], [{ key: { participant: '@hosted' }, extra: 1 }]), + 'without a rewrite there is nothing for this entry to excuse' + ) + }) + + it('keeps every shipped registry entry well-formed', async () => { + const { KNOWN_DIVERGENCES } = await import('../divergence.ts') + const ids = new Set() + for (const entry of KNOWN_DIVERGENCES) { + assert.ok(!ids.has(entry.id), `duplicate known-divergence id: ${entry.id}`) + ids.add(entry.id) + assert.ok(entry.reason.length > 20, `known-divergence ${entry.id} needs a reason a reviewer can audit`) + assert.match(entry.review, /^\d{4}-\d{2}-\d{2}$/u, `known-divergence ${entry.id} needs an ISO review date`) + } + }) +}) + +describe('fuzz harness — corpus', () => { + it('derives a filesystem-safe slug from a target name', () => { + assert.equal(corpusSlug('proto:Message.roundTrip'), 'proto-message-roundtrip') + assert.equal(corpusSlug('jid:jidDecode'), 'jid-jiddecode') + assert.equal(corpusSlug('!!!'), 'unnamed') + }) +}) + +describe('fuzz harness — protobuf wire canonicaliser', () => { + /** field, wire type 2, explicit length, payload. */ + const lengthDelimited = (field: number, payload: readonly number[]): Uint8Array => + Uint8Array.from([...tag(field, 2), payload.length, ...payload]) + + /** field, wire type 0, one varint value already encoded. */ + const varint = (field: number, encoded: readonly number[]): Uint8Array => + Uint8Array.from([...tag(field, 0), ...encoded]) + + // BigInt, not `field << 3`: the shift overflows int32 near the maximum legal + // field number, which would make the bounds test check the wrong bytes. + function tag(field: number, wireType: number): number[] { + let value = (BigInt(field) << 3n) | BigInt(wireType) + const bytes: number[] = [] + while (value > 0x7fn) { + bytes.push(Number((value & 0x7fn) | 0x80n)) + value >>= 7n + } + bytes.push(Number(value)) + return bytes + } + + const concat = (...parts: Uint8Array[]) => Uint8Array.from(parts.flatMap(part => [...part])) + + it('reads a packed run and its unpacked spelling as the same content', () => { + // field 22, packed [0, 1] against two separate varints. + const packed = lengthDelimited(22, [0x00, 0x01]) + const loose = concat(varint(22, [0x00]), varint(22, [0x01])) + assert.equal(differsOnlyByPacking(packed, loose, schemaFor([22])), true) + assert.equal(differsOnlyByPacking(loose, packed, schemaFor([22])), true) + + // Only for a field the schema declares repeated. Two varint occurrences of a + // *singular* field are a duplicate-field or wrong-wire-type regression, not + // a spelling of one repeated field — and this used to be excused, because + // the schema was consulted only when the loose side held a single value. + assert.equal(differsOnlyByPacking(packed, loose, schemaFor([])), false) + // With no schema the two readings are indistinguishable, so it is reported. + assert.equal(differsOnlyByPacking(packed, loose), false) + }) + + /** + * The regression that motivated carrying raw bytes on every field. + * + * `80 80 40 00` is a packed [1048576, 0], and it is *also* valid as a nested + * message (field 131072, wire type 0, value 0). The canonicaliser renders it as + * the nested form, so a packing check that unpacked the rendering rather than + * the bytes simply failed — and an ordinary two-element repeated field was + * reported as a codec mismatch. Six of these surfaced at once in `proto:oneof` + * the moment a generator change shifted the random stream, so it stays pinned. + */ + it('detects packing even when the packed payload also parses as a nested message', () => { + const packed = lengthDelimited(22, [0x80, 0x80, 0x40, 0x00]) + assert.match(canonicalWire(packed) ?? '', /\{131072:0:0\}/u, 'the payload should render as a nested message') + + const loose = concat(varint(22, [0x80, 0x80, 0x40]), varint(22, [0x00])) + assert.equal(differsOnlyByPacking(packed, loose, schemaFor([22])), true) + }) + + /** + * Occurrence order is a value, not a spelling. + * + * The comparator used to build a sorted key per field to decide "these entries + * are identical, skip". That made `08 01 08 02` and `08 02 08 01` compare + * equal, so the whole message came back as a packing difference and the proto + * targets reported clean — even though the two decode to `[1, 2]` and `[2, 1]` + * and neither side is a packed run at all. + */ + it('does not call a reordered repeated field a packing difference', () => { + const ascending = concat(varint(22, [0x01]), varint(22, [0x02])) + const descending = concat(varint(22, [0x02]), varint(22, [0x01])) + assert.equal(differsOnlyByPacking(ascending, descending, schemaFor([22])), false) + // Nested one level down, where the recursion does its own comparison. + assert.equal(differsOnlyByPacking(lengthDelimited(3, [...ascending]), lengthDelimited(3, [...descending])), false) + // And the genuinely identical pair still short-circuits. + assert.equal(differsOnlyByPacking(ascending, ascending), true) + }) + + /** + * The field-order class excuses a whole target, so it has to be asked the + * strict question. + * + * `sameWireContent` keeps only a varint's decoded value, so field 1 holding 1 + * written `08 81 00` looks identical to `08 01` — nothing was reordered, yet the + * pair canonicalised the same and the encode-bytes target routed it to + * `proto:field-order`, whose intended divergence waves it through. A codec that + * began emitting non-minimal tag, length or value varints could stay green. + */ + it('does not call a re-spelled varint a field-order difference', () => { + const minimal = concat(varint(1, [0x01]), varint(2, [0x02])) + const swapped = concat(varint(2, [0x02]), varint(1, [0x01])) + const respelledValue = concat(varint(1, [0x81, 0x00]), varint(2, [0x02])) + const respelledTag = concat(Uint8Array.from([0x88, 0x00, 0x01]), varint(2, [0x02])) + + // Reordering is still reordering, on the old question and the new one. + assert.equal(sameWireContent(minimal, swapped), true) + assert.equal(sameWireOrdering(minimal, swapped), true) + + // Re-spelling is not, though the old question could not tell. + assert.equal(sameWireContent(minimal, respelledValue), true) + assert.equal(sameWireOrdering(minimal, respelledValue), false) + assert.equal(sameWireContent(minimal, respelledTag), true) + assert.equal(sameWireOrdering(minimal, respelledTag), false) + + // A submessage whose fields merely moved still reads as ordering: the + // spelling recurses rather than comparing the parent's payload wholesale. + const nested = (payload: Uint8Array) => lengthDelimited(3, [...payload]) + assert.equal(sameWireOrdering(nested(minimal), nested(swapped)), true) + assert.equal(sameWireOrdering(nested(minimal), nested(respelledValue)), false) + }) + + it('reads a packing difference alongside a dropped field as an omission', () => { + // Same repeated field spelled both ways, and field 2 present on one side only. + const packed = lengthDelimited(22, [0x80, 0x80, 0x40, 0x00]) + const loose = concat(varint(22, [0x80, 0x80, 0x40]), varint(22, [0x00])) + assert.equal(isWireSubset(packed, concat(loose, varint(2, [0x09])), schemaFor([22])), true) + }) + + /** + * The single-value rule only takes effect with a schema, so without this the + * behaviour that decides between "excused packing" and "reported wrong wire + * type" — the distinction the schema context exists for — went untested. + */ + const schemaFor = (repeated: readonly number[]): SchemaContext => ({ + path: 'Test', + isRepeated: (_path, field) => repeated.includes(field), + messageAt: () => undefined + }) + + it('separates a one-element packed run from a wrong wire type using the schema', () => { + const packed = lengthDelimited(22, [0x07]) + const loose = varint(22, [0x07]) + assert.equal(differsOnlyByPacking(packed, loose, schemaFor([22])), true, 'field 22 is repeated: packing') + assert.equal(differsOnlyByPacking(packed, loose, schemaFor([])), false, 'field 22 is singular: wrong wire type') + // Without a schema the pair is ambiguous, so it is reported rather than excused. + assert.equal(differsOnlyByPacking(packed, loose), false) + }) + + it('parses a length-delimited field as a message only where the schema says so', () => { + // The payload frames as protobuf but the schema calls field 1 bytes, so + // reordering the bytes inside it is a changed value, not a field order. + const a = Uint8Array.from([0x0a, 0x04, 0x08, 0x01, 0x10, 0x02]) + const b = Uint8Array.from([0x0a, 0x04, 0x10, 0x02, 0x08, 0x01]) + const asBytes: SchemaContext = { path: 'Test', isRepeated: () => false, messageAt: () => undefined } + const asMessage: SchemaContext = { path: 'Test', isRepeated: () => false, messageAt: () => 'Nested' } + assert.equal(sameWireContent(a, b, asBytes), false) + assert.equal(sameWireContent(a, b, asMessage), true) + }) + + it('does not call a changed value a packing difference', () => { + const packed = lengthDelimited(22, [0x00, 0x01]) + assert.equal(differsOnlyByPacking(packed, concat(varint(22, [0x00]), varint(22, [0x02]))), false) + // A shorter run is data loss, not a spelling difference. + assert.equal(differsOnlyByPacking(packed, varint(22, [0x00])), false) + }) + + it('rejects a field number past the protobuf maximum', () => { + // 2^29 is one past the last legal field number, so the payload does not parse. + assert.equal(canonicalWire(Uint8Array.from([...tag(536_870_912, 0), 0x00])), undefined) + assert.notEqual(canonicalWire(Uint8Array.from([...tag(536_870_911, 0), 0x00])), undefined) + }) +}) diff --git a/src/__fuzz__/harness/compare.ts b/src/__fuzz__/harness/compare.ts new file mode 100644 index 00000000..91c756c1 Binary files /dev/null and b/src/__fuzz__/harness/compare.ts differ diff --git a/src/__fuzz__/harness/corpus.ts b/src/__fuzz__/harness/corpus.ts new file mode 100644 index 00000000..970a6ec2 --- /dev/null +++ b/src/__fuzz__/harness/corpus.ts @@ -0,0 +1,254 @@ +/** + * Corpus persistence. + * + * A fuzzer that only ever generates fresh input re-earns every find by luck. The + * corpus is the memory: each minimised failing input is written to + * `src/__fuzz__/corpus/.json` and replayed *before* random generation on + * every later run, so a fixed bug stays fixed even when the seed moves on. + * + * Corpus files are meant to be committed. They are small, they are the evidence + * behind a fix, and they cost a few milliseconds to replay. + */ + +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +// `URL.pathname` leaves percent escapes undecoded, so a checkout under a path +// with a space or a non-ASCII character would read and write the corpus +// somewhere else entirely. +export const CORPUS_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..', 'corpus') + +const BYTES_TAG = '__bytes__' +const BIGINT_TAG = '__bigint__' +const UNDEFINED_TAG = '__undefined__' +const NUMBER_TAG = '__number__' +const FUNCTION_TAG = '__function__' +const SYMBOL_TAG = '__symbol__' +const BUFFER_TAG = '__buffer__' +const DATE_TAG = '__date__' +const ERROR_TAG = '__error__' +const ESCAPE_TAG = '__escaped__' +const NULL_PROTO_TAG = '__nullproto__' +const HOSTILE_TAG = '__hostile__' + +/** + * Every tag `decode` recognises. + * + * `taggedAs` requires the tag be the object's only key, which makes a multi-key + * payload safe — but a payload that *is* exactly `{ __bytes__: 'AA' }` would + * round-trip as a Uint8Array. No generator emits those keys today; a new key pool + * would open it silently, so encode escapes the collision instead. + */ +const TAGS: readonly string[] = [ + NULL_PROTO_TAG, + HOSTILE_TAG, + BYTES_TAG, + BIGINT_TAG, + UNDEFINED_TAG, + NUMBER_TAG, + FUNCTION_TAG, + SYMBOL_TAG, + BUFFER_TAG, + DATE_TAG, + ERROR_TAG, + // The escape itself: a payload that is exactly `{ __escaped__: ... }` would + // otherwise be unwrapped on decode into whatever it wraps. + ESCAPE_TAG +] + +/** + * JSON cannot hold the values these fuzzers care most about, so tag them. + * + * `NaN`, `Infinity` and `-0` all survive `JSON.stringify` as `null` or `0`, and + * those are exactly the numbers a codec fuzzer plants on purpose — a corpus + * entry that replayed as `null` would stop reproducing what it was recorded for. + */ +const encode = (value: unknown): unknown => { + if (value === undefined) return { [UNDEFINED_TAG]: true } + if (typeof value === 'bigint') return { [BIGINT_TAG]: value.toString() } + if (typeof value === 'number' && (!Number.isFinite(value) || Object.is(value, -0))) { + return { [NUMBER_TAG]: Object.is(value, -0) ? '-0' : String(value) } + } + // Buffer before Uint8Array: `Buffer.isBuffer` is the narrower test, and the + // crypto helpers call Buffer methods on their arguments, so a recorded failure + // that replayed as a plain Uint8Array would exercise a different call. + if (Buffer.isBuffer(value)) return { [BUFFER_TAG]: value.toString('base64') } + if (value instanceof Uint8Array) return { [BYTES_TAG]: Buffer.from(value).toString('base64') } + // Date and Error have no enumerable own properties worth speaking of, so the + // generic object branch below would flatten either one to `{}` — and both are + // generated deliberately (`getCodeFromWSError` takes an Error; the value + // generator emits Dates). + // Stringified for the same reason the NUMBER_TAG branch stringifies: an invalid + // Date has a `NaN` time, JSON writes that as `null`, and `Number(null)` is 0 — + // so a reproducer recorded with `new Date(NaN)` would replay as the epoch. The + // generators emit invalid dates, so this path is reachable. + if (value instanceof Date) return { [DATE_TAG]: String(value.getTime()) } + if (value instanceof Error) { + return { [ERROR_TAG]: { name: value.name, message: value.message } } + } + if (Array.isArray(value)) return value.map(encode) + if (typeof value === 'object' && value !== null) { + // An object whose property reads throw. `argument-boundary.fuzz.test.ts` + // generates one deliberately — a proxy with a throwing `get` trap — and + // there is no way to recognise a proxy from outside, so it is probed + // instead. Untagged it serialised as `{}` (its `ownKeys` is empty, so the + // branch below never triggered the trap) and reloaded as an ordinary + // object, which cannot reproduce the failure it was recorded for. + // + // The identity is gone either way, as with the function and symbol tags; + // what the boundary checks read is the *behaviour*, so that is what is kept. + try { + void (value as Record).__corpus_probe__ + } catch { + return { [HOSTILE_TAG]: true } + } + const out: Record = {} + for (const [key, nested] of Object.entries(value)) { + // `__proto__` must stay an own key: the generators plant it deliberately, + // and plain assignment would move the prototype instead. + Object.defineProperty(out, key, { value: encode(nested), enumerable: true, writable: true, configurable: true }) + } + // A null-prototype object is recorded as one. `generateOffDomainValue` emits + // `Object.create(null)` deliberately, and it differs from `{}` exactly where + // the boundary guards look: string coercion throws instead of yielding + // `[object Object]`, and `in`/`toString`/`hasOwnProperty` find nothing. Left + // untagged, the entry reloaded with `Object.prototype` and a committed + // reproducer quietly stopped reproducing. + if (Object.getPrototypeOf(value) === null) return { [NULL_PROTO_TAG]: out } + // An ordinary object that happens to be exactly one tag key would decode as + // the tagged value, so it is wrapped once and unwrapped on the way back. + const keys = Object.keys(out) + if (keys.length === 1 && TAGS.includes(keys[0]!)) return { [ESCAPE_TAG]: out } + return out + } + // Functions and symbols are generated on purpose — `generateOffDomainValue` + // emits both as off-domain arguments — and JSON drops a property whose value + // is either one. Without a tag, `FUZZ_RECORD` wrote a corpus entry with no + // `input` at all, which reloads as `undefined` and replays a different case + // than the one that failed. Neither round-trips as the original object; what + // matters for the closed-domain checks is the *type*, so that is what is kept. + if (typeof value === 'function') return { [FUNCTION_TAG]: value.name || 'anonymous' } + if (typeof value === 'symbol') return { [SYMBOL_TAG]: value.description ?? '' } + return value +} + +/** A tag object is one that carries the tag *and nothing else*, so an ordinary + * payload with a colliding key is not mistaken for an encoded value. */ +const taggedAs = (record: Record, tag: string): boolean => + Object.hasOwn(record, tag) && Object.keys(record).length === 1 + +/** Decodes an object's values without re-testing the object itself for a tag. */ +const decodeProperties = (record: Record): Record => { + const out: Record = {} + for (const [key, nested] of Object.entries(record)) { + Object.defineProperty(out, key, { value: decode(nested), enumerable: true, writable: true, configurable: true }) + } + return out +} + +const decode = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(decode) + if (typeof value === 'object' && value !== null) { + const record = value as Record + // Unwrapped straight into the plain-object path, never back through `decode`: + // recursing would re-apply the tag checks one level down and hand back + // exactly the value the escape exists to prevent. + if (taggedAs(record, ESCAPE_TAG)) return decodeProperties(record[ESCAPE_TAG] as Record) + // Same unwrap-once rule as the escape tag, onto a null prototype. Properties + // are copied rather than assigned so an own `__proto__` stays an own key. + if (taggedAs(record, NULL_PROTO_TAG)) { + const bare = Object.create(null) as Record + for (const [key, nested] of Object.entries(record[NULL_PROTO_TAG] as Record)) { + Object.defineProperty(bare, key, { + value: decode(nested), + enumerable: true, + writable: true, + configurable: true + }) + } + return bare + } + if (taggedAs(record, HOSTILE_TAG)) { + return new Proxy( + {}, + { + get: () => { + throw new Error('hostile getter') + } + } + ) + } + if (taggedAs(record, UNDEFINED_TAG)) return undefined + if (taggedAs(record, BIGINT_TAG)) return BigInt(String(record[BIGINT_TAG])) + // Rebuilt as a fresh function/symbol carrying the recorded name. The + // identity is gone either way; the type is the part the check reads. + if (taggedAs(record, FUNCTION_TAG)) { + return Object.defineProperty(() => undefined, 'name', { value: String(record[FUNCTION_TAG]) }) + } + if (taggedAs(record, SYMBOL_TAG)) return Symbol(String(record[SYMBOL_TAG])) + if (taggedAs(record, BYTES_TAG)) return new Uint8Array(Buffer.from(String(record[BYTES_TAG]), 'base64')) + if (taggedAs(record, BUFFER_TAG)) return Buffer.from(String(record[BUFFER_TAG]), 'base64') + if (taggedAs(record, DATE_TAG)) return new Date(Number(record[DATE_TAG])) + if (taggedAs(record, ERROR_TAG)) { + const shape = record[ERROR_TAG] as { name?: string; message?: string } + const error = new Error(String(shape?.message ?? '')) + error.name = String(shape?.name ?? 'Error') + return error + } + if (taggedAs(record, NUMBER_TAG)) { + const raw = String(record[NUMBER_TAG]) + return raw === '-0' ? -0 : Number(raw) + } + return decodeProperties(record) + } + return value +} + +/** `proto:Message.roundTrip` → `proto-message-roundtrip`, safe as a filename. */ +export const corpusSlug = (target: string): string => + target + .replaceAll(/[^\dA-Za-z]+/gu, '-') + .replaceAll(/^-|-$/gu, '') + .toLowerCase() || 'unnamed' + +export interface CorpusEntry { + /** Free-form note describing what this input caught, written when it was recorded. */ + readonly note: string + readonly input: unknown +} + +const fileFor = (target: string): string => join(CORPUS_ROOT, `${corpusSlug(target)}.json`) + +export const loadCorpus = (target: string): CorpusEntry[] => { + const path = fileFor(target) + if (!existsSync(path)) return [] + try { + const parsed = JSON.parse(readFileSync(path, 'utf8')) as { note?: unknown; input?: unknown }[] + if (!Array.isArray(parsed)) return [] + return parsed.map(entry => ({ note: String(entry.note ?? ''), input: decode(entry.input) })) + } catch (error) { + // A corrupt corpus must not take the suite down with it: the fuzzer still + // works without its memory, it just forgets. + console.warn(`fuzz: ignoring unreadable corpus ${path}: ${(error as Error).message}`) + return [] + } +} + +/** Appends an entry, de-duplicating on the encoded input. */ +export const recordCorpus = (target: string, entry: CorpusEntry): void => { + mkdirSync(CORPUS_ROOT, { recursive: true }) + const existing = loadCorpus(target) + const encoded = JSON.stringify(encode(entry.input)) + if (existing.some(candidate => JSON.stringify(encode(candidate.input)) === encoded)) return + const next = [...existing, entry].map(candidate => ({ note: candidate.note, input: encode(candidate.input) })) + writeFileSync(fileFor(target), `${JSON.stringify(next, undefined, '\t')}\n`) +} + +/** Every target that has a stored corpus, for reporting. */ +export const corpusTargets = (): string[] => + existsSync(CORPUS_ROOT) + ? readdirSync(CORPUS_ROOT) + .filter(name => name.endsWith('.json')) + .map(name => name.slice(0, -'.json'.length)) + : [] diff --git a/src/__fuzz__/harness/divergence.ts b/src/__fuzz__/harness/divergence.ts new file mode 100644 index 00000000..b85967cb --- /dev/null +++ b/src/__fuzz__/harness/divergence.ts @@ -0,0 +1,1615 @@ +/** + * Known-divergence registry. + * + * Not every difference from upstream Baileys is a bug — some are the reason + * baileyrs exists. Without a place to record those on purpose, a differential + * fuzzer reports them on every run and the whole suite becomes noise people + * learn to skip. + * + * An entry is a claim with an owner and a date, not a mute button: + * - `reason` says why the difference is correct, in prose someone else can audit. + * - `review` is when the claim expires. Past that date the suite says so, and + * under `FUZZ_STRICT_ALLOWLIST=1` (the nightly job) it fails, which is what + * forces the entry to be re-argued instead of inherited forever. + * + * A run also reports entries that never matched: an allowlist that outlives the + * divergence it excused is how a real regression slips back in unnoticed. + */ + +import { normalise } from './compare.ts' + +export interface Divergence { + /** Fuzzer-scoped identity, e.g. `jid:jidDecode` or `proto:Message.roundTrip`. */ + readonly target: string + /** The generated input that produced the difference. */ + readonly input: unknown + /** What baileyrs produced. */ + readonly local: unknown + /** What upstream Baileys produced. */ + readonly upstream: unknown + /** Short human-readable summary of the difference. */ + readonly detail?: string +} + +/** + * `intended` — baileyrs behaves differently on purpose and the difference is + * correct. Silence is the right outcome. + * + * `open` — a real difference nobody has decided about yet. It is recorded so the + * suite stays green and the next run does not re-report it as news, but it is + * printed on *every* run and listed by `scripts/fuzz/report.ts`. An open entry is + * a tracked bug, not a resolved one; the distinction exists so that "we know + * about it" can never quietly become "it is fine". + */ +export type DivergenceStatus = 'intended' | 'open' + +export interface KnownDivergence { + /** Stable id, referenced from commit messages and issues. */ + readonly id: string + /** Matches `Divergence.target` exactly, or by pattern for a family of targets. */ + readonly target: string | RegExp + /** Whether the difference is deliberate, or merely known. */ + readonly status: DivergenceStatus + /** Why this difference is intended, or what is still undecided about it. */ + readonly reason: string + /** ISO date (YYYY-MM-DD) after which the claim has to be re-argued. */ + readonly review: string + /** Narrows the entry to the specific difference; omit to accept the whole target. */ + readonly when?: (divergence: Divergence) => boolean +} + +/** + * Fields upstream encodes and the bridge writes nothing for. + * + * Listed exactly, so a twelfth one fails the suite instead of joining them + * quietly. Found by the `proto:field-numbers` sweep once it stopped skipping the + * case where only the bridge produced no bytes. + */ +const NOT_ENCODED_FIELDS: readonly string[] = [ + 'Message.AudioMessage.mediaKeyDomain', + 'Message.DocumentMessage.mediaKeyDomain', + 'Message.ImageMessage.mediaKeyDomain', + 'Message.MMSThumbnailMetadata.mediaKeyDomain', + 'Message.StickerMessage.mediaKeyDomain', + 'Message.VideoMessage.mediaKeyDomain', + 'Message.MessageHistoryMetadata.oldestMessageTimestamp', + 'Message.PaymentExtendedMetadata.messageParamsJson', + 'SyncActionValue.businessBroadcastAssociationAction', + 'SyncActionValue.AgentAction.deviceID', + 'SyncActionValue.ChatAssignmentAction.deviceAgentID' +] + +/** + * The exact fields the bridge round-trips under another name. + * + * Enumerated rather than pattern-matched: "the names differ" would excuse any + * future rename, which is the failure this entry exists to catch. The list comes + * from the exhaustive `proto:field-names` sweep, so it is complete as of writing + * and any addition to it will fail the suite first. + */ +const RENAMED_PROTO_FIELDS: readonly (readonly [upstream: string, bridge: string])[] = [ + ['deviceAgentID', 'deviceAgentId'], + ['deviceID', 'deviceId'], + ['oldestMessageTimestamp', 'oldestMessageTimestampInWindow'] +] + +/** + * Rewrites the bridge's spelling of every renamed field back to upstream's, + * recursively. + * + * The point is what happens after: if the two sides are then equal, the rename is + * the entire difference and the entry legitimately explains it. If anything else + * still differs, the finding contains a second defect and must not be excused — + * matching on "both names appear somewhere in the text" alone would have let a + * decode regression ride along beside a known rename. + */ +export const undoRenames = (value: unknown, depth = 0): unknown => { + if (depth > 12 || typeof value !== 'object' || value === null) return value + if (Array.isArray(value)) return value.map(item => undoRenames(item, depth + 1)) + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + const rename = RENAMED_PROTO_FIELDS.find(([, bridgeName]) => bridgeName === key) + Object.defineProperty(out, rename ? rename[0] : key, { + value: undoRenames(nested, depth + 1), + enumerable: true, + writable: true, + configurable: true + }) + } + return out +} + +/** + * The keys of a plain object, or `undefined` for anything else. + * + * Predicates that want to say "decoded nothing" have to distinguish an empty + * message from a `null`, a string or an array — `Object.keys` answers all four + * and only one of them is the claim being made. + */ +const plainObject = (value: unknown): string[] | undefined => + typeof value === 'object' && value !== null && !Array.isArray(value) ? Object.keys(value) : undefined + +/** `showOutcome` renders a thrown result as this prefix; a returned value is passed through raw. */ +const isThrow = (value: unknown): value is string => typeof value === 'string' && value.startsWith(' { + if (typeof input === 'string') return input + const path = (input as { path?: unknown } | null | undefined)?.path + return typeof path === 'string' ? path : undefined +} + +/** + * The ten explicit-presence fields the bridge encoder drops at their zero value. + * + * Enumerated rather than left to the target name. The `proto:presence` sweep + * covers all 1696 proto3-optional fields, and the whole value of a sweep is that + * an eleventh has to fail rather than be absorbed into the entry describing the + * ten. (`BotAvatarMetadata`'s five presence fields are not here: the bridge does + * not implement that type at all, so they route to the unknown-type entry.) + */ +const PRESENCE_DROPPED_FIELDS: readonly string[] = [ + 'Message.AudioMessage.mediaKeyDomain', + 'Message.DocumentMessage.mediaKeyDomain', + 'Message.ImageMessage.mediaKeyDomain', + 'Message.MMSThumbnailMetadata.mediaKeyDomain', + 'Message.StickerMessage.mediaKeyDomain', + 'Message.VideoMessage.mediaKeyDomain', + 'Message.MessageHistoryMetadata.oldestMessageTimestamp', + 'Message.PaymentExtendedMetadata.messageParamsJson', + 'SyncActionValue.AgentAction.deviceID', + 'SyncActionValue.ChatAssignmentAction.deviceAgentID' +] + +/** The message type the bridge codec does not implement. */ +const UNKNOWN_CODEC_TYPES: readonly string[] = ['BotAvatarMetadata'] + +/** The messages whose schema reaches it — where a `{ path, message }` finding names the holder. */ +const UNKNOWN_CODEC_HOLDERS: readonly string[] = ['BotMetadata', 'Message', 'MessageContextInfo'] + +/** The individual fields the sweeps name, either on the missing type or holding one. */ +const UNKNOWN_CODEC_FIELDS: readonly string[] = [ + 'BotAvatarMetadata.action', + 'BotAvatarMetadata.behaviorGraph', + 'BotAvatarMetadata.intensity', + 'BotAvatarMetadata.sentiment', + 'BotAvatarMetadata.wordCount', + 'BotMetadata.avatarMetadata' +] + +/** + * True when a finding names a type the bridge codec does not implement. + * + * Three input shapes reach this entry and they match differently, which is why + * the lists are separate rather than one prefix test: the inventory sweep passes + * a bare type name, the field sweeps pass `Type.field` (with the presence sweep + * appending ` = `), and the byte-level classifier passes `{ path }` naming + * the *holder*. Prefix-matching the holders instead would make `Message` cover + * every nested message type in the schema. + * + * Pinned rather than left to the runtime probe behind the target: that probe is + * what makes the entry self-retiring once the bridge implements the type, but it + * would equally route a type the bridge *loses* straight into this entry as + * though it had always been here. + */ +const namesUnknownCodecType = (input: unknown): boolean => { + if (typeof input === 'string') { + const spec = input.split(' = ')[0]! + return UNKNOWN_CODEC_TYPES.includes(spec) || UNKNOWN_CODEC_FIELDS.includes(spec) + } + const path = inputPath(input) + return path !== undefined && (UNKNOWN_CODEC_TYPES.includes(path) || UNKNOWN_CODEC_HOLDERS.includes(path)) +} + +/** + * The two key fields `cleanMessage` re-encodes through `jidNormalizedUser`. + * + * Both, not just `remoteJid`: the same empty-user rewrite is observable on + * `participant`, and an entry scoped to one field reported the other as an + * unrelated finding. Measured — `{ key: { participant: '@hosted' } }` with an + * empty meId leaves `@hosted` in baileyrs and becomes `@s.whatsapp.net` + * upstream, exactly as `remoteJid` does. + */ +const CLEANED_JID_FIELDS = new Set(['remoteJid', 'participant']) + +/** + * True when upstream is baileyrs plus JID keys that all hold the empty string. + * + * That is the whole of the `cleanMessage` difference: for a key with no + * remoteJid or participant, upstream writes `jidNormalizedUser(undefined)` — + * `''` — onto the caller's object where baileyrs leaves the property absent. + * Anything else, including a changed value or a key upstream is missing, is a + * different defect and still fails. + * + * "JID keys" is the part that has to be checked rather than assumed. Without it + * the rule read "upstream has an extra key holding `''`", which is also true of + * a dropped empty `conversation` or `reactionMessage.text` — measured, both were + * excused as the known missing-JID difference. Only the two fields + * `cleanMessage` actually normalises may appear this way. + */ +const addsOnlyEmptyStrings = (local: unknown, upstream: unknown, depth = 0): boolean => { + if (depth > 12) return false + if (Array.isArray(local) || Array.isArray(upstream)) { + if (!Array.isArray(local) || !Array.isArray(upstream) || local.length !== upstream.length) return false + return local.every( + (item, index) => sameShape(item, upstream[index]) || addsOnlyEmptyStrings(item, upstream[index], depth + 1) + ) + } + if (typeof local !== 'object' || typeof upstream !== 'object' || local === null || upstream === null) { + return sameShape(local, upstream) + } + const a = local as Record + const b = upstream as Record + // A key baileyrs has and upstream does not is the reverse of the claim. + for (const key of Object.keys(a)) if (!Object.hasOwn(b, key) && a[key] !== undefined) return false + for (const key of Object.keys(b)) { + if (!Object.hasOwn(a, key) || a[key] === undefined) { + if (b[key] !== '' || !CLEANED_JID_FIELDS.has(key)) return false + continue + } + if (!sameShape(a[key], b[key]) && !addsOnlyEmptyStrings(a[key], b[key], depth + 1)) return false + } + return true +} + +/** + * Replaces every normalised key JID with a sentinel, recursively. + * + * Lets a predicate say "apart from those JIDs, these agree" without + * hand-walking the argument tuple the mutation target reports. + */ +const maskKeyJids = (value: unknown, depth = 0): unknown => { + if (depth > 12 || typeof value !== 'object' || value === null) return value + if (Array.isArray(value)) return value.map(item => maskKeyJids(item, depth + 1)) + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + Object.defineProperty(out, key, { + // The empty string is left alone rather than masked. It is the *other* + // entry's subject — upstream materialising a missing JID as `''` — and + // masking it to the same sentinel as a real JID stopped + // `addsOnlyEmptyStrings` from recognising it, so a key that hit both + // differences at once matched neither entry. + value: + CLEANED_JID_FIELDS.has(key) && typeof nested === 'string' && nested !== '' + ? '' + : maskKeyJids(nested, depth + 1), + enumerable: true, + writable: true, + configurable: true + }) + } + return out +} + +/** + * Every non-empty normalised key JID a divergence side carries, by path. + * + * Keyed by path rather than collected into a list, because the two sides are + * then compared field to field: a `remoteJid` on one side lining up with a + * `participant` on the other is a different defect from the one this documents, + * and must not be excused by it. + */ +const keyJids = (value: unknown, prefix = '', found = new Map(), depth = 0): Map => { + if (depth > 12 || typeof value !== 'object' || value === null) return found + if (Array.isArray(value)) { + for (const [index, item] of value.entries()) keyJids(item, `${prefix}[${index}]`, found, depth + 1) + return found + } + for (const [key, nested] of Object.entries(value as Record)) { + const path = `${prefix}.${key}` + if (CLEANED_JID_FIELDS.has(key) && typeof nested === 'string') { + if (nested !== '') found.set(path, nested) + } else keyJids(nested, path, found, depth + 1) + } + return found +} + +/** + * Substitutes U+FFFD for every unpaired surrogate in every string, recursively. + * + * Three of them per surrogate, not one: upstream copies content through + * `proto.Message.decode(proto.Message.encode(content))`, protobufjs writes the + * surrogate as three WTF-8 bytes, and decoding those back as UTF-8 yields three + * replacement characters. Measured on `\ud800`. + */ +const replaceLoneSurrogates = (value: unknown, depth = 0): unknown => { + if (depth > 12) return value + if (typeof value === 'string') { + return value.replaceAll( + /[\ud800-\udbff](?![\udc00-\udfff])|(? replaceLoneSurrogates(item, depth + 1)) + if (typeof value !== 'object' || value === null) return value + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + Object.defineProperty(out, key, { + value: replaceLoneSurrogates(nested, depth + 1), + enumerable: true, + writable: true, + configurable: true + }) + } + return out +} + +/** The largest finite float32; anything above it is what the bridge refuses. */ +const FLT_MAX = 3.4028234663852886e38 + +/** + * True when the generated input carries a number the float32 range cannot hold. + * + * Walked structurally rather than matched in the serialised text: the codec + * targets pass `{ path, message }` and the presence sweep passes a + * `Path.field = value` string, and in both the interesting part is a magnitude, + * not a substring. + */ +const carriesOutOfRangeFloat = (value: unknown, depth = 0): boolean => { + if (depth > 12) return false + if (typeof value === 'number') return !Number.isFinite(value) || Math.abs(value) > FLT_MAX + if (typeof value === 'string') { + const parsed = Number(value.split(' = ').at(-1)) + return Number.isFinite(parsed) ? Math.abs(parsed) > FLT_MAX : false + } + if (Array.isArray(value)) return value.some(item => carriesOutOfRangeFloat(item, depth + 1)) + if (typeof value !== 'object' || value === null) return false + return Object.values(value as Record).some(nested => carriesOutOfRangeFloat(nested, depth + 1)) +} + +/** + * Every top-level field the bridge encoder is known to drop. + * + * Measured rather than sampled: nine seeds and 21,000 generated cases produce + * exactly these twelve `path#number` pairs and no others. A thirteenth is new + * data loss and must be looked at, which is the entire point of listing them. + */ +const KNOWN_OMITTED_FIELDS: ReadonlySet = new Set([ + 'BotMetadata#1', + 'Message.AudioMessage#23', + 'Message.DocumentMessage#22', + 'Message.ImageMessage#33', + 'Message.MMSThumbnailMetadata#8', + 'Message.MessageHistoryMetadata#2', + 'Message.PaymentExtendedMetadata#3', + 'Message.StickerMessage#23', + 'Message.VideoMessage#32', + 'SyncActionValue#65', + 'SyncActionValue.AgentAction#2', + 'SyncActionValue.ChatAssignmentAction#1' +]) + +/** + * True when a finding carries a classification its target computed for it. + * + * Targets append `[tag]` to the detail — and `[tag-a; tag-b]` when more than one + * applies, which is why this looks inside the brackets rather than matching them + * whole. An earlier version anchored on `[tag]` and silently stopped matching + * the moment a second classification joined it, quietly un-excusing a + * documented difference. + */ +const hasTag = (divergence: Divergence, tag: string): boolean => { + const detail = divergence.detail ?? '' + const start = detail.lastIndexOf('[') + if (start < 0 || !detail.endsWith(']')) return false + return detail + .slice(start + 1, -1) + .split('; ') + .includes(tag) +} + +/** + * True when `value` holds a 64-bit magnitude outside ±(2^53−1). + * + * Strings as well as numbers: the generator seeds 64-bit fields as decimal + * strings, because that is how protobufjs renders them and how a caller would + * supply one. `9007199254740991` is inside the range and must not match — it is + * the exact boundary the entry says still decodes. + */ +const carriesBeyondSafeInteger = (value: unknown, depth = 0): boolean => { + if (depth > 12) return false + if (typeof value === 'number') return Number.isFinite(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER + if (typeof value === 'bigint') + return value > BigInt(Number.MAX_SAFE_INTEGER) || value < -BigInt(Number.MAX_SAFE_INTEGER) + if (typeof value === 'string') { + if (!/^-?\d+$/u.test(value)) return false + const parsed = BigInt(value) + return parsed > BigInt(Number.MAX_SAFE_INTEGER) || parsed < -BigInt(Number.MAX_SAFE_INTEGER) + } + if (Array.isArray(value)) return value.some(item => carriesBeyondSafeInteger(item, depth + 1)) + if (typeof value !== 'object' || value === null) return false + return Object.values(value as Record).some(nested => carriesBeyondSafeInteger(nested, depth + 1)) +} + +/** + * True when the input really carries an empty string and the bridge still encoded. + * + * The entry documents one coercion, and keying on upstream's error text alone + * excused every local outcome — including the bridge dropping the field or + * writing something else entirely. This ties the excuse to an input that + * actually holds the empty string, and to the bridge having produced bytes + * rather than nothing. + * + * It stops short of proving the coerced field encoded as *zero*: the message + * carries other populated fields whose values are legitimately non-zero, so + * telling the coerced field from its neighbours needs the schema, which this + * registry has no access to. That is the remaining gap, and it is smaller than + * the one it replaces. + */ +const coercedAnEmptyString = (divergence: Divergence): boolean => { + const carriesEmptyString = (value: unknown, depth = 0): boolean => { + if (depth > 12) return false + if (value === '') return true + if (Array.isArray(value)) return value.some(item => carriesEmptyString(item, depth + 1)) + if (typeof value !== 'object' || value === null) return false + return Object.values(value as Record).some(nested => carriesEmptyString(nested, depth + 1)) + } + if (!carriesEmptyString(divergence.input)) return false + // And the bridge really did coerce it to zero. The registry cannot tell the + // coerced field from its neighbours — that needs the schema — so the target + // answers instead: it re-encodes the same message with every empty string + // replaced by `'0'` and tags the finding with whether the bytes match. + // Measured on `Message.AudioMessage.fileLength`, `''` and `'0'` both encode to + // `0a017520002803` where `'5'` gives `0a017520052803`, so a regression that + // wrote a different value or dropped the field is tagged `not coerced` and + // stops being excused here. + return hasTag(divergence, 'empty string coerced to zero') +} + +/** Removes one property wherever it appears, so a predicate can ask what is left. */ +const withoutKey = (value: unknown, name: string, depth = 0): unknown => { + if (depth > 12) return value + if (Array.isArray(value)) return value.map(item => withoutKey(item, name, depth + 1)) + if (typeof value !== 'object' || value === null) return value + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + if (key === name) continue + Object.defineProperty(out, key, { + value: withoutKey(nested, name, depth + 1), + enumerable: true, + writable: true, + configurable: true + }) + } + return out +} + +/** + * Replaces every string with a placeholder, leaving the structure. + * + * The two decoders resolve invalid UTF-8 into *different characters*, not into a + * known substitution — the bridge writes one U+FFFD per bad byte, protobufjs + * runs its own reader and produces whatever it makes of them. So there is no + * character class to fold: what can be checked is that the difference is + * confined to text at all. Masking the strings and requiring the rest to agree + * rules out a dropped field, a changed number, a different nesting — everything + * except the text itself. + * + * The residual gap is a regression that changed some *other* string field while + * one field happened to hold invalid UTF-8. Closing that needs the decoders to + * report which bytes they could not read. + */ +const maskStrings = (value: unknown, depth = 0): unknown => { + if (depth > 12) return value + if (typeof value === 'string') return '' + if (Array.isArray(value)) return value.map(item => maskStrings(item, depth + 1)) + if (typeof value !== 'object' || value === null) return value + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + Object.defineProperty(out, key, { + value: maskStrings(nested, depth + 1), + enumerable: true, + writable: true, + configurable: true + }) + } + return out +} + +/** + * True when two poll aggregates hold the same options and voters, in any order. + * + * The entry claims ordering and nothing else, so that is what has to be checked: + * a voter moved to the wrong bucket, a dropped voter or a renamed option all + * change the multiset and must still be reported. + */ +const samePollAggregate = (left: unknown, right: unknown): boolean => { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false + const key = (entry: unknown): string => { + if (typeof entry !== 'object' || entry === null) return text(entry) + const record = entry as Record + const voters = Array.isArray(record.voters) ? record.voters.map(voter => text(voter)).toSorted() : record.voters + return text({ ...record, voters }) + } + return text(left.map(key).toSorted()) === text(right.map(key).toSorted()) && text(left) !== text(right) +} + +/** + * True when `value` holds a number an int64 field cannot represent exactly. + * + * Which is what makes the two implementations disagree: `1.5`, `NaN`, + * `Infinity` and `1e300` are all numbers, and none of them is a 64-bit integer. + * Safe integers and numeric strings are excluded, because both sides handle + * those identically — measured on `senderTimestampMs`, where `12345`, `'12345'`, + * `0`, `null` and `undefined` all encode byte-for-byte the same. + */ +const carriesInexactInt64 = (value: unknown, depth = 0): boolean => { + if (depth > 12) return false + if (typeof value === 'number') return !Number.isSafeInteger(value) + if (Array.isArray(value)) return value.some(item => carriesInexactInt64(item, depth + 1)) + if (typeof value !== 'object' || value === null) return false + return Object.values(value as Record).some(nested => carriesInexactInt64(nested, depth + 1)) +} + +/** + * Replaces each side's int64 truncation with one sentinel, in place of the pair. + * + * A sentinel rather than a verdict, so this composes with the key-presence entry + * instead of duplicating it: one message can exhibit both differences at once — + * measured on `{ pollUpdateMessage: { pollCreationMessageKey: { remoteJidAlt: + * '' }, senderTimestampMs: -1.5 } }`, where the round trip both truncates the + * timestamp and drops the empty alt JID — and a predicate that demanded matching + * key sets then matched neither entry. + * + * `normalise` folds a safe integer into a bigint and leaves everything else a + * number, so "baileyrs kept a float, upstream holds an integer" is exactly + * `typeof local === 'number'` against `typeof upstream === 'bigint'`. A changed + * *integer*, a changed string, or a dropped field is none of those, stays + * unmasked, and still fails. + */ +const maskInt64Truncations = (local: unknown, upstream: unknown, depth = 0): [unknown, unknown] => { + if (depth > 12) return [local, upstream] + // The leaf rule: a non-integer (or NaN/Infinity) against any integer, and + // `null` against the field's zero default, which is what the round trip writes + // for a numeric field that was not set to a number at all. + if (typeof local === 'number' && typeof upstream === 'bigint') return ['', ''] + if (local === null && upstream === 0n) return ['', ''] + if (Array.isArray(local) && Array.isArray(upstream) && local.length === upstream.length) { + const pairs = local.map((item, index) => maskInt64Truncations(item, upstream[index], depth + 1)) + return [pairs.map(pair => pair[0]), pairs.map(pair => pair[1])] + } + if (typeof local !== 'object' || typeof upstream !== 'object' || local === null || upstream === null) { + return [local, upstream] + } + if (Array.isArray(local) || Array.isArray(upstream)) return [local, upstream] + const a = local as Record + const b = upstream as Record + const maskedLocal: Record = {} + const maskedUpstream: Record = {} + // Only the keys both carry are paired up. A key on one side alone is the + // key-presence entry's subject and is passed through untouched, so that entry + // still has to account for it. + for (const key of Object.keys(a)) maskedLocal[key] = a[key] + for (const key of Object.keys(b)) maskedUpstream[key] = b[key] + for (const key of Object.keys(a)) { + if (!Object.hasOwn(b, key)) continue + const [left, right] = maskInt64Truncations(a[key], b[key], depth + 1) + maskedLocal[key] = left + maskedUpstream[key] = right + } + return [maskedLocal, maskedUpstream] +} + +/** Every `contextInfo` removed, so the rest of a tuple can be compared alone. */ +const withoutContextInfo = (value: unknown, depth = 0): unknown => { + if (depth > 12 || typeof value !== 'object' || value === null) return value + if (Array.isArray(value)) return value.map(item => withoutContextInfo(item, depth + 1)) + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + if (key === 'contextInfo') continue + out[key] = withoutContextInfo(nested, depth + 1) + } + return out +} + +/** The keys `generateForwardMessageContent` is allowed to leave in a `contextInfo`. */ +const FORWARDING_KEYS: ReadonlySet = new Set(['forwardingScore', 'isForwarded']) + +/** + * True when every `contextInfo` that differs between the two sides is one + * baileyrs wrote the forwarding metadata into, and nothing else differs. + * + * Positional, not global. The mutation oracle reports one finding for the whole + * argument tuple, so "baileyrs mutated the argument" was true of the documented + * write *and* of anything that rode along with it. But requiring *every* + * `contextInfo` to hold only forwarding keys is the opposite error: a + * `contextInfo` the caller supplied deeper in the message is legitimately left + * alone — measured on a `deviceSentMessage` whose inner `extendedTextMessage` + * keeps its own `participant` while the forwarding metadata is written at the + * wrapper level. So each position is compared against upstream's, and only a + * `contextInfo` holding exactly the forwarding keys may differ. + */ +const onlyForwardingContextInfoDiffers = (local: unknown, upstream: unknown, depth = 0): boolean => { + if (depth > 12) return false + if (text(local) === text(upstream)) return true + if (Array.isArray(local) || Array.isArray(upstream)) { + if (!Array.isArray(local) || !Array.isArray(upstream) || local.length !== upstream.length) return false + return local.every((item, index) => onlyForwardingContextInfoDiffers(item, upstream[index], depth + 1)) + } + if (typeof local !== 'object' || typeof upstream !== 'object' || local === null || upstream === null) return false + const a = local as Record + const b = upstream as Record + for (const key of new Set([...Object.keys(a), ...Object.keys(b)])) { + if (text(a[key]) === text(b[key])) continue + if (key === 'contextInfo') { + // Added or replaced, either way it must hold the forwarding keys alone. + const written = a[key] + if (typeof written !== 'object' || written === null) return false + if (!Object.keys(written as Record).every(inner => FORWARDING_KEYS.has(inner))) return false + continue + } + if (!Object.hasOwn(a, key) || !Object.hasOwn(b, key)) return false + if (!onlyForwardingContextInfoDiffers(a[key], b[key], depth + 1)) return false + } + return true +} + +/** + * What `generateForwardMessageContent`'s two copy strategies are allowed to + * differ by, once every documented normalisation has been undone. + * + * One helper rather than three predicates, because the differences co-occur. + * Upstream copies through `proto.Message.decode(proto.Message.encode(content))` + * and baileyrs shallow-clones, so a single message can show all of them at once + * — measured on `{ pollUpdateMessage: { pollCreationMessageKey: { remoteJid: + * '\ud83d' }, senderTimestampMs: -1.5 } }`, where the round trip substitutes the + * lone surrogate, truncates the timestamp *and* drops the added `contextInfo`. + * Each entry below still needs its own trigger to say which difference it is + * about; what they share is what is allowed to remain afterwards. + * + * Anything the normalisations do not explain — a changed body, a changed + * integer, a dropped field with a value — survives and still fails. + */ +const copyStrategyResidue = (local: unknown, upstream: unknown): boolean => { + const [mine, theirs] = maskInt64Truncations(replaceLoneSurrogates(local), upstream) + return differsOnlyByKeyPresence(mine, theirs) +} + +/** + * The two event kinds whose buffered merges resolve differently. + * + * Not every kind: `chats.update` twice, and `groups.update` where the later one + * carries no subject, were measured alongside these and agree. Listing the two + * rather than allowing any kind is what stops this entry from covering a + * consolidation difference nobody has looked at. + */ +const MERGE_PRECEDENCE_KINDS: ReadonlySet = new Set(['contacts.upsert', 'groups.update']) + +/** + * True when the two released sequences differ only in the fields of those + * kinds, for the same ids. + * + * Order-insensitive, and deliberately so. The release-order entry above + * documents that the two buffers interleave kinds differently, and the two + * differences co-occur constantly — measured, the finding that motivated this + * entry has `contacts.upsert` and `message-receipt.update` swapped *and* a + * contact field differing, so an index-wise comparison matched neither entry. + * Observations are paired by kind and identity first, then what is left has to + * be a field difference on one of those kinds. Composing this way is what the + * copy-strategy entries do, for the same reason. + * + * Still narrow: the multiset of kinds has to match, the ids have to match, at + * least one field has to actually differ, and any difference on any other + * release fails. + */ +const mergePrecedenceFieldsOnly = (local: unknown, upstream: unknown): boolean => { + if (!Array.isArray(local) || !Array.isArray(upstream) || local.length !== upstream.length) return false + + const idsOf = (data: unknown): string => + Array.isArray(data) + ? text(data.map(entry => (typeof entry === 'object' && entry !== null ? (entry as { id?: unknown }).id : entry))) + : text(data) + + // A contacts.upsert pairs on its ids alone; everything else pairs on its whole + // content, so a changed payload elsewhere cannot find a partner and fails. + const pairKey = (item: unknown): string => { + const released = (item as { released?: unknown })?.released + return typeof released === 'string' && MERGE_PRECEDENCE_KINDS.has(released) + ? `${released}\u0000${idsOf((item as { data?: unknown }).data)}` + : text(item) + } + + const remaining = new Map() + for (const item of upstream) { + const key = pairKey(item) + remaining.set(key, [...(remaining.get(key) ?? []), item]) + } + + let differing = 0 + for (const mine of local) { + const key = pairKey(mine) + const bucket = remaining.get(key) + if (bucket === undefined || bucket.length === 0) return false + const theirs = bucket.shift() + if (text(mine) !== text(theirs)) differing++ + } + // Something has to have differed *inside* a contacts.upsert. Without this the + // entry would excuse a pure reordering, which is the sibling entry's subject. + return differing > 0 +} + +/** + * True when two values agree on every key they share, differing only by which + * keys are present. + * + * Neither side needs to be a subset of the other. The two copy strategies in + * `generateForwardMessageContent` differ in both directions at once — upstream's + * protobuf round trip materialises empty repeated fields and drops undeclared + * ones — so a one-directional subset test does not describe it. Any *value* the + * two both carry and disagree on still fails. + */ +const differsOnlyByKeyPresence = (left: unknown, right: unknown): boolean => { + // Counts the shared keys the walk actually compared. Without it the object + // branch returns `true` vacuously for two objects that share no key at all — + // the loop body never runs — so `{ conversation: 'x' }` against + // `{ imageMessage: {} }` reads as "differs only by key presence" and a + // content object replaced wholesale would be excused. Nothing was compared, + // so nothing was verified, and the entry must not claim otherwise. + const state = { compared: 0 } + return walkKeyPresence(left, right, 0, state) && state.compared > 0 +} + +const walkKeyPresence = (left: unknown, right: unknown, depth: number, state: { compared: number }): boolean => { + if (depth > 12) return false + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false + return left.every((item, index) => { + if (sameShape(item, right[index])) { + state.compared++ + return true + } + return walkKeyPresence(item, right[index], depth + 1, state) + }) + } + if (typeof left !== 'object' || typeof right !== 'object' || left === null || right === null) { + return sameShape(left, right) + } + const a = left as Record + const b = right as Record + const shared = Object.keys(a).filter(key => Object.hasOwn(b, key)) + + // Every unmatched key, checked against what the round trip actually does: + // it materialises fields the schema declares (an empty repeated field, so + // `[]`) and drops properties it does not (the scalars a caller tacked on). + // + // Applied to every level, not only to a level with no shared keys — which is + // where this used to stop. With one key in common the loop below ran and the + // unmatched ones were never looked at, so a *dropped message body* was + // excused: `{ extendedTextMessage: { contextInfo } }` against + // `{ extendedTextMessage: { text: 'the body', contextInfo } }` shares + // `contextInfo`, and `text` went unexamined. Losing a message body is the + // single thing this suite exists to catch, so it now has to be one of the two + // documented artifacts or nothing. + for (const key of Object.keys(b)) { + if (Object.hasOwn(a, key)) continue + // Upstream-only: the materialised empty repeated field, and nothing else. + if (!Array.isArray(b[key]) || (b[key] as unknown[]).length > 0) return false + } + for (const key of Object.keys(a)) { + if (Object.hasOwn(b, key)) continue + // baileyrs-only: a scalar the schema does not declare, which is what the + // round trip drops. An object here would be a whole subtree upstream lost. + if (typeof a[key] === 'object' && a[key] !== null) return false + } + + for (const key of shared) { + state.compared++ + if (!sameShape(a[key], b[key]) && !walkKeyPresence(a[key], b[key], depth + 1, state)) return false + } + return true +} + +/** Structural equality over the plain values a divergence carries. */ +const sameShape = (left: unknown, right: unknown): boolean => { + if (typeof left !== typeof right) return false + if (typeof left !== 'object' || left === null || right === null) return Object.is(left, right) + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false + return left.every((item, index) => sameShape(item, right[index])) + } + const a = left as Record + const b = right as Record + const keys = Object.keys(a) + if (keys.length !== Object.keys(b).length) return false + return keys.every(key => Object.hasOwn(b, key) && sameShape(a[key], b[key])) +} + +/** + * Renders either side of a divergence for a predicate to match against. + * + * `String(value)` yields "[object Object]" for the decoded objects these + * predicates inspect, which silently makes every `includes` check false — the + * entry then excuses nothing and the finding reappears as unexplained. + */ +const text = (value: unknown): string => { + if (typeof value === 'string') return value + try { + return ( + JSON.stringify(value, (_key, nested: unknown) => (typeof nested === 'bigint' ? nested.toString() : nested)) ?? '' + ) + } catch { + return '' + } +} + +/** + * Every property an empty object inherits. + * + * Enumerated from the prototype itself rather than written out, so the entry that + * relies on it cannot drift from what the runtime actually inherits. + */ +// Object.prototype only: the adapter table is a plain object literal, so that is +// the whole of its prototype chain. Including Function.prototype names would +// excuse a genuine unrecognised event type called `bind` or `name`. +const PROTOTYPE_KEYS: ReadonlySet = new Set(Object.getOwnPropertyNames(Object.prototype)) + +/** + * An unpaired UTF-16 surrogate, which is what the newsletter encoder differs on. + * + * Serialised input renders surrogates as `\udXXX` escapes, so the check runs + * against both the raw character and its escaped form. + */ +const LONE_SURROGATE = + /[\ud800-\udbff](?![\udc00-\udfff])|(? { + if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) return false + const key = (items: unknown[]) => + items + .map(item => text(item)) + .toSorted() + .join('\u0000') + return key(left) === key(right) && text(left) !== text(right) +} + +/** + * The registry. + * + * Every entry below was produced by a run of these fuzzers, not by guesswork, + * and most carry a minimised reproducer under `src/__fuzz__/corpus/`. + * + * Several of the proto entries are *rediscoveries*, not discoveries: the + * schema-level gaps — the mediaKeyDomain presence drops, the three renamed + * fields, the pollResultSnapshotMessageV3 field number, the unimplemented + * BotAvatarMetadata — are already tracked by `KNOWN_WIRE_GAPS` and + * `KNOWN_UNSUPPORTED_CODECS` in `scripts/compatibility/proto-runtime-audit.ts`, + * and pinned by `scripts/compatibility/__tests__/wire-fidelity.test.ts`. Each + * such entry says so. That the fuzzers reached them independently, from + * generated input, is evidence the sweeps work — it is not new information, and + * recording it as new would misrepresent what this suite found. + */ +export const KNOWN_DIVERGENCES: readonly KnownDivergence[] = [ + { + id: 'to-number-high-word', + target: 'pure:toNumber', + status: 'intended', + reason: + 'Upstream returns `t.low` for a Long without a toNumber method, silently dropping the high word and truncating any value past 2^32 (a millisecond timestamp, for one). baileyrs reconstructs `high * 2^32 + (low >>> 0)`, which is documented at the call site. Upstream also returns its argument unchanged for a non-Long, non-number input, where baileyrs returns 0 to honour its `number` return type. And `toNumber(-0)` returns `-0` from baileyrs and `+0` from upstream, because `t || 0` treats negative zero as falsy — observable through `Object.is` and `1 / result`.', + review: '2027-02-01', + when: divergence => { + // `??` only replaces null/undefined, and a corpus entry or a shrunk input + // need not be an array. Destructuring a plain object here would throw + // inside applyAllowlist, which has no catch, and fail the run with an + // error unrelated to the finding. + if (!Array.isArray(divergence.input)) return false + const [argument] = divergence.input as unknown[] + // The outcomes, not just the input shape. Both rules are spelled out and + // each side is held to its own: matching on "the argument was a Long" + // alone excused a regression that threw, reconstructed the wrong sign or + // returned a different constant — on the very inputs the entry is about. + if (isThrow(divergence.local) || isThrow(divergence.upstream)) return false + if (typeof argument === 'object' && argument !== null) { + // With a `toNumber` method both call it, so they agree or both throw. + // Neither outcome is this entry. + if ('toNumber' in argument) return false + const { low, high } = argument as { low?: unknown; high?: unknown } + // Upstream is `t.low` verbatim, including `undefined` for an object + // carrying no such property — which is most generated objects. + if (!sameShape(divergence.upstream, low)) return false + return typeof low === 'number' + ? Object.is(divergence.local, (typeof high === 'number' ? high : 0) * 0x1_0000_0000 + (low >>> 0)) + : divergence.local === 0 + } + // `-0` is the one number the two disagree on, and for the same reason as + // the rest of this entry: upstream's `t || 0` treats it as falsy and hands + // back `+0`, baileyrs returns the argument. Surfaced once `normalise` + // stopped folding the sign away under strict comparison. + if (Object.is(argument, -0)) { + return Object.is(divergence.local, -0) && Object.is(divergence.upstream, 0) + } + if (typeof argument === 'number') return false + // Non-object, non-number: upstream is `t || 0`, baileyrs is 0. + return divergence.local === 0 && sameShape(divergence.upstream, argument || 0) + } + }, + { + id: 'newsletter-encode-lone-surrogate', + // Two helpers, one encoder behaviour. `generateForwardMessageContent` copies + // upstream's content through the protobuf codec, so a lone surrogate in any + // string field comes back as U+FFFD there too — measured, `\ud800` becomes + // three replacement characters upstream and stays raw in baileyrs, which does + // not round-trip. The predicate keeps this to inputs that actually carry one. + target: /^pure:(encodeNewsletterMessage|generateForwardMessageContent)$/u, + status: 'open', + // Scoped to the surrogate, not the whole helper. Without a predicate this + // excused every return-value difference from the encoder, so a regression + // that changed ordinary text or dropped a field would have been counted as + // the known WTF-8 case. + // The documented substitution, checked as such. `LONE_SURROGATE.test(input)` + // alone excused every difference on a message that merely contained one, so + // a regression that changed ordinary text or dropped a field rode along. + // + // Two shapes reach here. The encoder returns bytes: the Rust side writes the + // well-formed `ef bf bd` where protobufjs writes the WTF-8 form, so the + // local output has to carry the replacement sequence and the upstream one + // must not. The forwarding helper returns content: substituting U+FFFD for + // each lone surrogate on the baileyrs side has to close the gap, since the + // protobuf round trip upstream expands one surrogate into three replacement + // characters. + when: divergence => { + if (!LONE_SURROGATE.test(text(divergence.input))) return false + if (isThrow(divergence.local) || isThrow(divergence.upstream)) return false + if (divergence.local instanceof Uint8Array && divergence.upstream instanceof Uint8Array) { + // Byte-for-byte apart from the substitution itself. Testing only that + // `ef bf bd` appears on one side and not the other let any other byte + // difference ride along whenever some field happened to hold a lone + // surrogate. Both spellings collapse to the same placeholder, and what + // is left has to be identical. + const fold = (bytes: Uint8Array) => + // Byte pairs, space-separated, so a match can never begin on an odd + // nibble. Unanchored, `ed[ab][0-9a-f]{3}` matched inside `0edab012` + // and consumed the low half of one byte plus part of the next — + // removing bytes that are not substitutions, on one side only, so + // the two folds no longer cancel and a real byte difference could + // pass as this entry. + (Buffer.from(bytes).toString('hex').match(/../gu) ?? []) + .join(' ') + // The Rust encoder's U+FFFD, and protobufjs's WTF-8 surrogate, + // spelled out in full: `ed a0 80` .. `ed bf bf`. The trailing byte + // is a UTF-8 continuation, so it is `80`..`bf` — not any byte. + // Leaving it as `[0-9a-f]{2}` folded `ed a0 00`, which is not a + // surrogate at all, and folding a non-substitution on one side + // only is exactly what stops the two folds from cancelling. + .replaceAll(/ef bf bd|ed [ab][0-9a-f] [89ab][0-9a-f]/gu, '') + const mine = fold(divergence.local) + return mine === fold(divergence.upstream) && mine.includes('') + } + // Composed with the sibling copy-strategy entries rather than duplicating + // them: once the surrogate substitution is undone, what is allowed to + // remain is the key presence and int64 conversion those entries already + // document. A changed body still fails all of them. + return copyStrategyResidue(normalise(divergence.local), normalise(divergence.upstream)) + }, + reason: + 'A string field holding an unpaired UTF-16 surrogate is handled differently on each side. Encoding: protobufjs emits the WTF-8 form (U+DFFF becomes ed bf bf, which is not valid UTF-8) where the Rust encoder substitutes U+FFFD (ef bf bd) — the Rust output is the well-formed one, but the wire bytes differ. Copying: `generateForwardMessageContent` shows the same thing from the other side, because upstream copies content through the protobuf codec and baileyrs does not, so `\ud800` survives in baileyrs and becomes U+FFFD upstream. Needs a maintainer call on whether to match upstream or keep sanitising.', + review: '2026-11-01' + }, + { + id: 'binary-node-messages-tolerates-bad-payload', + target: 'pure:getBinaryNodeMessages', + status: 'open', + // Exactly one side throwing, *and* a stanza that actually carries a payload + // the reference decoder cannot read back. One-sided-throw alone was not the + // whole claim: the generator builds `` children carrying real, + // populated WebMessageInfo bytes in its dominant branch, so a regression + // that started throwing on a perfectly good stanza satisfied it too. + // + // Whether a payload is well-formed cannot be decided here — a truncated + // WebMessageInfo prefix is still valid protobuf, so a structural parse + // cannot tell it from a whole one, and this module deliberately depends on + // nothing but the harness. So the target tags its own input, where both + // libraries are already imported, and this reads the tag. Measured over 400 + // draws: all 42 one-sided throws carry a malformed payload, none is tagged + // `well-formed payloads`. + // + // If both decoded and disagreed, that is a decode difference on a payload + // they both accepted, and it still fails. + when: divergence => + isThrow(divergence.local) !== isThrow(divergence.upstream) && hasTag(divergence, 'malformed payload'), + reason: + 'The two decoders disagree about which malformed `` payloads are readable, in both directions. Upstream throws "illegal buffer" where baileyrs returns an empty message object; and for a truncated length prefix (`2a 16` with no body) baileyrs throws RangeError "premature EOF" where upstream returns `[{ participant: "" }]`. Whichever way round, a corrupt stanza becomes an empty message on one side and an exception on the other, so a caller cannot write one handler that works against both. Needs a maintainer call on which contract the stanza handlers should rely on.', + review: '2026-11-01' + }, + { + id: 'get-history-msg-throws-instead-of-undefined', + target: 'pure:getHistoryMsg', + status: 'open', + // The generator now emits valid history-sync notifications, so the entry has + // to say which half of that it covers: only the *missing* one. Read + // structurally rather than by searching the serialised input — a + // notification nested under `deviceSentMessage` appears in the text but is + // not where either helper looks, and both correctly ignore it. + when: divergence => { + if (!isThrow(divergence.local) || divergence.upstream !== undefined) return false + const message = Array.isArray(divergence.input) ? divergence.input[0] : undefined + const protocol = (message as { protocolMessage?: Record } | undefined)?.protocolMessage + return protocol?.historySyncNotification === undefined + }, + reason: + 'Upstream returns `undefined` when the message carries no history-sync notification; baileyrs throws a Boom 400. Drop-in consumer code written as `const h = getHistoryMsg(msg); if (!h) return` therefore crashes against baileyrs. The fix is a signature change on a published API, so it belongs in its own commit rather than in the change that found it.', + review: '2026-11-01' + }, + { + id: 'clean-message-empty-jid-normalisation', + target: /^pure:cleanMessage/u, + status: 'open', + // One substitution, checked as such. Without a predicate the pattern excused + // every difference on every generated message, so altered content or a + // mis-normalised JID was absorbed — which is exactly what had been hiding + // the empty-user server difference below. + when: divergence => + !isThrow(divergence.local) && + !isThrow(divergence.upstream) && + addsOnlyEmptyStrings(normalise(divergence.local), normalise(divergence.upstream)), + reason: + 'For a message key with no remoteJid/participant, upstream writes the empty string (via jidNormalizedUser(undefined)) while baileyrs writes undefined. Both are falsy and downstream behaviour matches, but the key objects differ for anything that inspects them. Only reachable with a malformed key.', + review: '2026-11-01' + }, + { + id: 'clean-message-empty-user-jid-server', + target: /^pure:cleanMessage/u, + status: 'open', + // Found by narrowing the entry above, which had been excusing every + // difference on the target and so was covering this one too. + when: divergence => { + if (isThrow(divergence.local) || isThrow(divergence.upstream)) return false + const mine = keyJids(normalise(divergence.local)) + const theirs = keyJids(normalise(divergence.upstream)) + // Same JID fields on both sides. A JID baileyrs writes where upstream + // writes nothing at all — or the reverse — is not this. + if (mine.size === 0 || mine.size !== theirs.size) return false + let rewritten = 0 + for (const [path, jid] of mine) { + const other = theirs.get(path) + if (other === undefined) return false + if (other === jid) continue + // Differing: both must be the empty-user form this entry is about. + if (!jid.startsWith('@') || !other.startsWith('@')) return false + rewritten++ + } + // At least one, or nothing here is the documented rewrite and whatever + // else differs is being excused for free. + if (rewritten === 0) return false + return addsOnlyEmptyStrings(maskKeyJids(normalise(divergence.local)), maskKeyJids(normalise(divergence.upstream))) + }, + reason: + "For a message key whose remoteJid or participant normalises to an empty user, the two write different servers onto the caller's key. Measured directly: `_99:1@hosted` becomes `@hosted` in baileyrs and `@s.whatsapp.net` upstream; `_1@hosted.lid` becomes `@hosted.lid` in baileyrs and `@lid` upstream; `{ key: { participant: '@hosted' } }` shows the same rewrite on the participant field. `jidNormalizedUser` agrees on all of those in isolation, so the difference is in cleanMessage's own re-encoding, and baileyrs is the side that preserves what the server actually sent. A consumer keying chats by remoteJid therefore files these under different chats depending on the library. Only reachable with a JID whose user part is empty.", + review: '2026-11-01' + }, + { + id: 'forward-message-content-mutates-input', + // The mutation target exactly, not the family: the pattern also matched the + // base return-value target, so a helper that started returning different + // forwarded content was excused as the known mutation. Narrowing it revealed + // the second difference below, which had been hiding there. + target: 'pure:generateForwardMessageContent#mutation', + status: 'open', + // Confined to the documented mutation, which it was not before: the oracle + // reports one finding for the whole argument tuple, so "baileyrs mutated the + // argument" was satisfied by the forwarding write *and* by anything else + // that rode along with it — a replaced `conversation`, a deleted field. Two + // conditions now. Everything outside `contextInfo` has to be untouched, and + // every `contextInfo` baileyrs left behind has to hold forwarding metadata + // and nothing else. + when: divergence => { + if (isThrow(divergence.local) || isThrow(divergence.upstream)) return false + const mine = normalise(divergence.local) + const theirs = normalise(divergence.upstream) + // Two conditions rather than one, because either alone is escapable. + // Everything outside `contextInfo` identical rules out a changed body or + // a deleted field; the positional walk then rules out a `contextInfo` + // changed to anything but the forwarding metadata. + if (text(withoutContextInfo(mine)) !== text(withoutContextInfo(theirs))) return false + return onlyForwardingContextInfoDiffers(mine, theirs) + }, + reason: + "baileyrs replaces `contextInfo` on the caller's own message object with `{ forwardingScore, isForwarded }`; upstream leaves the argument untouched and returns new content. Two consequences, and the second is the sharper one: forwarding a message mutates the original in one library and not the other, and because it *replaces* rather than merges, a caller who forwards a quoted message finds `stanzaId` and `participant` gone from their own object afterwards. Measured on `{ extendedTextMessage: { text: 'x', contextInfo: { stanzaId: 'abc', participant: 'a@s.whatsapp.net' } } }`: the baileyrs argument comes back holding only the two forwarding keys, the upstream argument comes back unchanged. Both return values drop the quote metadata, so that part is agreed behaviour; only the write to the caller's object differs. Root cause is the copy: baileyrs shallow-clones with `{ ...content }`, so the nested message object is shared with the caller, where upstream rebuilds it via `proto.Message.decode(proto.Message.encode(content))`.", + review: '2026-11-01' + }, + { + id: 'forward-message-content-copy-shape', + target: 'pure:generateForwardMessageContent', + status: 'open', + // Confined to key presence, in either direction — which is exactly what the + // two copy strategies differ by. A changed *value*, a dropped body or wrong + // forwarding metadata is not a subset either way round, and still fails. + // Keyed on the target's own round-trip check, not on a structural guess. + // The registry has no protobuf runtime, so it cannot tell a declared field + // from an undeclared one — and that is the whole difference between an + // artifact of the copy and a lost message body. The target answers instead: + // it re-runs upstream's copy over baileyrs' result and tags whether that + // reproduces upstream's. A structural rule got this wrong in both directions + // before, first excusing a dropped `extendedTextMessage.text` and then + // reporting a legitimately dropped undeclared property. + when: divergence => hasTag(divergence, 'copy strategy'), + reason: + "The same root cause as the mutation entry, seen in the return value: upstream copies the content through `proto.Message.decode(proto.Message.encode(content))` while baileyrs shallow-clones with `{ ...content }`. The round trip changes the key set in both directions — it drops properties the schema does not declare, and it materialises empty repeated fields the schema does declare. Measured on `{ extendedTextMessage: {} }`: upstream's result carries `endCardTiles: []`, baileyrs' does not; on `{ extendedTextMessage: { text: 'x', notAField: 1 } }` baileyrs keeps `notAField` and upstream loses it. Schema-valid content with no empty repeated fields agrees exactly, so callers are largely unaffected — but it is a second observable of one defect, and the mutation entry's over-broad target had been excusing it.", + review: '2026-11-01' + }, + { + id: 'forward-message-content-int64-truncation', + target: 'pure:generateForwardMessageContent', + status: 'open', + // A third observable of the copy strategy, on values rather than keys, so it + // needs its own predicate: the sibling entry above is confined to key + // presence and a changed value is exactly what it must never excuse. + // + // Composed with that entry rather than restating it. One message commonly + // shows both — the round trip truncates a timestamp *and* drops an empty alt + // JID — so this masks the truncations and then requires what is left to be + // the key-presence difference that entry already documents. A changed + // integer, a changed string or a changed body survives the mask and fails. + when: divergence => { + if (isThrow(divergence.local) || isThrow(divergence.upstream)) return false + if (!carriesInexactInt64(divergence.input)) return false + const mine = normalise(divergence.local) + const [masked] = maskInt64Truncations(mine, normalise(divergence.upstream)) + // The mask has to have fired: without this the entry would excuse any + // pure key-presence difference, which is the sibling entry's job. + if (text(masked) === text(mine)) return false + return copyStrategyResidue(mine, normalise(divergence.upstream)) + }, + reason: + "For a 64-bit integer field holding a number that is not a 64-bit integer, the two copy strategies disagree on the value as well as the key set. Upstream's `proto.Message.decode(proto.Message.encode(content))` converts through Long, so `senderTimestampMs: 1.5` comes back as 1, `-1.5` as -1, and `NaN`, `Infinity` and `1e300` all as 0; baileyrs shallow-clones and hands back the float it was given. Measured on `pollUpdateMessage.senderTimestampMs`. Safe integers, numeric strings, `null` and `undefined` agree exactly on both sides, so only a caller passing a fractional or non-finite timestamp is affected — but the two then forward different values, and the same conversion applies to every int64 field in the schema.", + review: '2026-11-01' + }, + { + id: 'newsletter-encode-rejects-inexact-int64', + target: 'pure:encodeNewsletterMessage', + status: 'open', + // The rejecting side, one of the two rejection messages, *and* an input that + // actually carries such a number. The error text alone would let a + // regression that started throwing the same error on an ordinary integer be + // excused as this; `carriesInexactInt64` is what stops that. + // + // Two messages, because the bridge rejects in two places: the BigInt + // conversion catches a non-integer or non-finite value, and its own range + // check catches an integer-valued double outside int64. Both are "a number + // an int64 cannot carry", so both belong here; a *third* message would not + // be covered and would surface, which is the intent. + when: divergence => + isThrow(divergence.local) && + !isThrow(divergence.upstream) && + /cannot be converted to a BigInt|invalid int64/iu.test(text(divergence.local)) && + carriesInexactInt64(divergence.input), + reason: + 'For a 64-bit integer field holding a number that is not a 64-bit integer, the bridge encoder throws where upstream converts and encodes. Two rejections, one cause: `1.5`, `NaN` and `Infinity` fail the BigInt conversion (`RangeError: The number … cannot be converted to a BigInt`), and `1e300`, `2**63` and `1.5625e19` fail the range check (`Error: invalid int64: …`). Upstream accepts every one of them — `1.5` as 1, `NaN`/`Infinity`/`1e300` as 0, and `1.5625e19` as a wrapped value that is not the number it was given. Safe integers, numeric strings, `null` and `undefined` are byte-identical on both sides. Measured on `pollUpdateMessage.senderTimestampMs`. baileyrs is plainly the more correct side here — upstream silently sends a wrong value where baileyrs refuses — but it is caller-visible either way: the same content sends on Baileys and throws on baileyrs. Same shape as the float32 entry, and the pair should be decided together.', + review: '2026-11-01' + }, + { + id: 'bridge-adapter-prototype-chain-lookup', + target: /^bridge:adapt-(unknown|total)$/u, + status: 'open', + reason: + 'The adapter table is a plain object literal indexed by the event type string, so a type of "constructor", "toString" or "valueOf" resolves through Object.prototype: the inherited function is called and its return value is handed on as a canonical event, and "__proto__" resolves to a non-function and throws "adapter is not a function". The type comes from the runtime, which gets it from the server, so an untrusted string is indexing a prototype-bearing lookup table. Both outcomes break the layer\'s stated contract of dropping what it does not recognise. A Map, an Object.create(null) table, or an Object.hasOwn guard fixes it.', + review: '2026-10-01', + when: divergence => PROTOTYPE_KEYS.has(String((divergence.input as { type?: unknown })?.type)) + }, + { + id: 'bridge-adapter-throws-on-missing-data', + target: /^bridge:adapt-(total|coverage)$/u, + status: 'open', + reason: + 'Adapters for declared event types read straight into `data` without checking it is there, so an event that arrives with no data slot — or with a slot missing the field the adapter reads — throws a TypeError instead of returning null. `adapt.ts` documents the opposite ("Result is null on unrecoverable shape mismatch"), and the throw does not stay local: it propagates into the socket event dispatch, which takes out the whole event loop rather than the one event.', + review: '2026-10-01', + // The throw shape *and* a `data` slot that is actually missing. On its own + // the regex matches the most common TypeErrors there are — "is not a + // function", "is not iterable" — so an adapter regression on a well-shaped + // event was classified as the known missing-data problem. The per-type + // coverage counter cannot catch that either: it sees a type that never + // adapts at all, not one that fails on one payload in eight. + when: divergence => { + if (!MISSING_DATA_THROWS.test(text(divergence.local))) return false + const data = (divergence.input as { data?: unknown } | undefined)?.data + // Absent, not a record at all, or a record with nothing in it — the three + // shapes an adapter reading straight into `data` cannot survive. + return plainObject(data)?.length !== undefined ? plainObject(data)!.length === 0 : true + } + }, + { + id: 'event-buffer-merge-precedence', + target: 'buffer:differential', + status: 'open', + // Confined to the fields inside a `contacts.upsert` or `groups.update` + // release, for the same ids. Everything else in the released sequence has to + // match, so a lost event, a changed id, or a difference on any other release + // still fails. + when: divergence => mergePrecedenceFieldsOnly(divergence.local, divergence.upstream), + reason: + "The two buffers resolve a merge to different winners, and neither is consistently the newer one. Measured directly, buffering each pair and flushing: two `groups.update` for the same id with subjects 'first' then 'second' — baileyrs releases 'second', upstream releases 'first'; a `contacts.update` with name 'first' followed by a `contacts.upsert` with name 'second' — baileyrs releases 'first', upstream releases 'second'. So each library keeps the stale value on one of the two kinds and the fresh one on the other, and a consumer sees a different contact name or group subject depending on which library it is running. `chats.update` twice, and a `groups.update` whose second event carries no subject, agree — as do the four message-level consolidation branches (update into upsert, reaction and delete and receipt against a buffered upsert), all measured at the same time. Found only once the buffer generator started drawing ids from a shared pool: before that no two buffered events ever referred to the same entity, so none of this ran.", + review: '2026-11-01' + }, + { + id: 'event-buffer-release-order', + target: 'buffer:differential', + status: 'open', + // Ordering only. Without this predicate the entry would also excuse a + // corrupted payload, a different consolidation result or a different throw — + // all of which reach `buffer:differential`, and none of which + // `buffer:conservation` can see, since it only counts event names. + when: divergence => isPermutation(divergence.local, divergence.upstream), + reason: + "Flushing a buffer that holds several event kinds releases them in a different order than upstream: for the same sequence, baileyrs emitted contacts.upsert before message-receipt.update where upstream emitted them the other way round. No event is lost — buffer:conservation is clean — but a consumer whose handlers assume upstream's ordering (contacts populated before receipts reference them) sees a different interleaving.", + review: '2026-11-01' + }, + { + id: 'proto-decode-above-max-safe-integer', + // Also matched on the wire fuzzer, where the same ceiling stops the library + // reading back bytes it just sent. + target: /^(proto|wire):/u, + status: 'open', + reason: + 'The bridge decoder throws "Value is larger than Number.MAX_SAFE_INTEGER" (or the MIN_SAFE_INTEGER counterpart) for any 64-bit field outside +/-(2^53-1), where protobufjs decodes it to a Long. The boundary is exact: 9007199254740991 decodes, 9007199254740992 throws. This is not a precision difference — the whole message fails to decode, so a legitimate server payload with a large fileLength or a microsecond timestamp becomes an error rather than a message. The most severe finding in this suite.', + review: '2026-10-01', + // The rejecting side, the error, *and* a value that is actually outside the + // range the reason names. Keyed on the text alone, a decoder that started + // raising the same error at the wrong threshold — on 9007199254740991, which + // this entry says decodes, or on an ordinary small integer — was + // indistinguishable from the documented case, on every proto and wire + // target. The bridge is the side with the ceiling, so an upstream mention + // does not qualify either. + // + // The magnitude is looked for in the input *or* in what upstream produced, + // because the targets hand this entry different shapes: the encode-side ones + // carry the generated message, while decode-parity carries raw bytes — there + // the offending value appears only in upstream's decoded object, which is + // still evidence independent of the side that threw. + when: divergence => + text(divergence.local).includes('SAFE_INTEGER') && + (carriesBeyondSafeInteger(divergence.input) || carriesBeyondSafeInteger(divergence.upstream)) + }, + { + id: 'proto-field-renamed-and-dropped', + // Any proto target: the rename surfaces on the naming sweep, on decode + // parity, and on round-trip, always as two objects carrying the same value + // under different keys. The `when` predicate below is what keeps the entry + // narrow — it names the three fields exactly. + target: /^proto:/u, + status: 'open', + when: divergence => { + const named = RENAMED_PROTO_FIELDS.some( + ([upstreamName, bridgeName]) => + text(divergence.local).includes(bridgeName) && text(divergence.upstream).includes(upstreamName) + ) + if (!named) return false + // Naming both spellings is not enough on its own. The rename has to be the + // *whole* difference: undo it and the two sides must agree, or this finding + // is carrying a second defect that the entry does not explain. + // + // The field-name sweep reports strings rather than objects — the decoded + // key list against the expected key — and there the name match *is* the + // finding. But it has to be the whole key list, not a substring of it. + // + // `local` is `keys.join(', ')`, so a decoder that materialised *both* + // spellings reported `deviceAgentID, deviceAgentId` against upstream's + // `deviceAgentID`, and the substring test above passed on the joined + // string. That is not this entry's rename — it is a decoder inventing a + // duplicate property, which the sweep even reports under its own detail + // ("plus keys upstream never encoded") — and it was being excused. + // + // Exact equality on both sides: one declared name in, one renamed key out. + if (typeof divergence.local !== 'object' || typeof divergence.upstream !== 'object') { + return ( + divergence.target === 'proto:field-names' && + RENAMED_PROTO_FIELDS.some( + ([upstreamName, bridgeName]) => divergence.local === bridgeName && divergence.upstream === upstreamName + ) + ) + } + return sameShape(undoRenames(divergence.local), undoRenames(divergence.upstream)) + }, + reason: + 'Three fields round-trip under a different property name than upstream declares, and the bridge encoder silently drops the upstream spelling: SyncActionValue.ChatAssignmentAction.deviceAgentID becomes deviceAgentId, SyncActionValue.AgentAction.deviceID becomes deviceId, and Message.MessageHistoryMetadata.oldestMessageTimestamp becomes oldestMessageTimestampInWindow. The property name is the public API — code written against the upstream types reads undefined, and writes are lost with no error at all. ALREADY TRACKED: all three are in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts; the sweep rediscovered them from generated input rather than finding them.', + review: '2026-10-01' + }, + { + id: 'proto-explicit-presence-zero-dropped', + target: 'proto:presence', + status: 'open', + // Named, not target-wide. The sweep's whole point is that it covers every + // proto3-optional field; an entry matching the target alone would route an + // eleventh drop into the finding that describes the ten and leave the + // nightly green on a new regression. + when: divergence => + typeof divergence.input === 'string' && + PRESENCE_DROPPED_FIELDS.some( + field => divergence.input === `${field} = 0` || divergence.input === `${field} = ""` + ), + reason: + 'An explicit-presence (proto3 optional) field set to its zero value is not encoded by the bridge, where protobufjs writes it. 10 of the 1696 such fields are affected, including mediaKeyDomain on all six media message types (image, video, audio, document, sticker, thumbnail). Explicit presence exists precisely so a zero can be distinguished from unset, so this loses information the schema was written to carry. ALREADY TRACKED: every affected field appears in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts. What is new here is only the count and the exhaustive sweep behind it.', + review: '2026-10-01' + }, + { + id: 'proto-field-not-encoded', + // Both finite sweeps see it: the number sweep as "nothing encoded", the name + // sweep as "nothing decoded". One absent field, two views of it. + target: /^proto:field-(names|numbers)$/u, + status: 'open', + // Enumerated, not pattern-matched: the value of the sweep is that it covers + // every non-map field, so a twelfth field joining this list has to fail + // rather than be absorbed. + // + // And the outcome is pinned as well as the field. Each sweep reports several + // different things — a wrong field number, a rename, a one-sided rejection — + // so matching on the field name alone would have excused a *renumbering* of + // any of these eleven, which is a different and worse defect with its own + // entry. + when: divergence => + (divergence.local === '' || divergence.local === '') && + NOT_ENCODED_FIELDS.some(field => text(divergence.input).includes(field)), + reason: + 'Upstream encodes these fields and the bridge writes nothing at all for them. Eleven of 2421 non-map fields: mediaKeyDomain on all six media types, MessageHistoryMetadata.oldestMessageTimestamp, PaymentExtendedMetadata.messageParamsJson, SyncActionValue.businessBroadcastAssociationAction, AgentAction.deviceID and ChatAssignmentAction.deviceAgentID. ALREADY TRACKED: every one is in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts — the six presence drops and the two renames also have their own entries here, seen from a different angle. The sweep previously skipped the case where only the bridge produced no bytes, so it reported exhaustive coverage of fields it had not checked; this entry is what that skip was hiding.', + review: '2026-10-01' + }, + { + id: 'proto-field-omission', + target: 'proto:field-omission', + status: 'open', + // Named, not target-wide. The structural classifier proves the bridge's bytes + // are upstream's *minus whole fields*, which rules out a changed value but not + // a newly dropped one — so a future regression dropping a field nothing else + // covers counted as another hit of this entry and left the nightly green. + // + // Pinning the *message* path was tried and reverted on measurement: one smoke + // seed named 17, four named 29, and the set kept growing. The omitted *field* + // is a different question with a different answer — 12 distinct `path#number` + // pairs, identical across nine seeds and 21,000 generated cases. The + // encode-bytes target reports them; a finding that omits anything else is not + // this entry. + when: divergence => { + const detail = divergence.detail ?? '' + const marker = detail.indexOf('omits ') + // The views that carry no byte-level tag keep the structural bound alone: + // round-trip and the field sweeps compare decoded objects, where the omitted + // field numbers are not available. Narrowing those needs the same treatment + // and is the remaining gap in this entry. + if (marker < 0) return true + const listed = detail + .slice(marker + 'omits '.length) + .split(/[;\]]/u)[0]! + .split(',') + return listed.every(entry => KNOWN_OMITTED_FIELDS.has(entry.trim())) + }, + reason: + 'Cases where the bridge output is upstream output minus whole fields — an empty nested message, a sub-field of a type it models differently. Classified by structural subset rather than by name, so a *changed* value can never land here: those still fail as encode-bytes or decode-parity. The twelve top-level fields it covers are listed in KNOWN_OMITTED_FIELDS and were measured, not sampled: identical across nine seeds and 21,000 generated cases. Overlaps the presence and unknown-type entries, which are themselves already tracked in KNOWN_WIRE_GAPS.', + review: '2026-10-01' + }, + { + id: 'proto-float32-out-of-range-rejected', + // The wire fuzzer too, for the same reason `proto-decode-above-max-safe-integer` + // covers both: the send path encodes through the same bridge, so a float above + // FLT_MAX makes `relayMessage` throw there exactly as `encodeProto` throws + // here. It only started surfacing once `wire:upstream-readable` stopped + // discarding its own relay failures on the assumption that `wire:fidelity` + // had seen the same input — the two targets draw from different streams. + target: /^(proto|wire):/u, + status: 'open', + reason: + 'For a 32-bit float field given a double above FLT_MAX (3.4028234663852886e38), the bridge throws "invalid float32" and protobufjs encodes it anyway — silently, to Infinity. baileyrs is the stricter and arguably the correct one here, but the difference is caller-visible: the same value sends on Baileys and throws on baileyrs. Exact FLT_MAX itself is accepted by both; an earlier version of this entry claimed otherwise because the generator emitted the rounded literal 3.4028235e38, which is a larger double.', + review: '2026-11-01', + // The message, the rejecting side, *and* a value that is actually out of + // range. The message alone let a regression that started rejecting an + // in-range 1.5, 0.1 or exact FLT_MAX with the same generic error be + // classified as this known difference, on every proto target. + when: divergence => text(divergence.local).includes('invalid float32') && carriesOutOfRangeFloat(divergence.input) + }, + { + id: 'proto-decode-invalid-utf8', + target: 'proto:mutation-agreement', + status: 'open', + reason: + 'When a string field carries bytes that are not valid UTF-8, the two decoders produce different strings: the bridge substitutes U+FFFD per undecodable byte, protobufjs runs its own reader and resolves the same bytes into different characters. Both accept the payload, so a peer sending malformed UTF-8 hands the two libraries different text. Same root cause as the lone-surrogate difference on the encode side, and the pair should be decided together.', + review: '2026-11-01', + // The substitution has to be the *only* difference. A mutated payload can + // carry several populated fields, so keying on "a replacement character + // appears somewhere" let a decoder regression that also dropped or changed + // another field ride alongside one U+FFFD. Both sides have their + // undecodable text folded to a single placeholder, and the rest must agree. + when: divergence => { + if (!text(divergence.local).includes('\uFFFD')) return false + return sameShape(maskStrings(normalise(divergence.local)), maskStrings(normalise(divergence.upstream))) + } + }, + { + id: 'proto-malformed-interpretation', + target: 'proto:mutation-interpretation', + status: 'intended', + reason: + 'Bytes that do not frame as protobuf at all — a length prefix longer than the buffer, a varint with no terminator — have no defined meaning, so two decoders that both salvage something from them are not required to salvage the same thing. Payloads that *are* well-formed protobuf are held to strict agreement under proto:mutation-agreement, which is where a real decoder bug would land.', + review: '2027-02-01' + }, + { + id: 'proto-field-number-mismatch', + // `wire:upstream-readable` specifically, not every wire target. That one + // compares two decodes of the *same* bytes, which is the only place a field + // number disagreement can be the cause: `wire:fidelity` compares the bridge + // against itself and `wire:message-builder` compares objects keyed by name, + // so a real regression on either would have been excused whenever the + // generated input happened to carry this field. + target: /^proto:|^wire:upstream-readable$/u, + status: 'open', + reason: + 'Message.pollResultSnapshotMessageV3 is field 115 in the bridge codec and field 114 upstream — the only such disagreement across all 2421 non-map fields. ALREADY TRACKED, and documented in exactly these terms: scripts/compatibility/__tests__/wire-fidelity.test.ts pins it as KNOWN_DIVERGENT and proto-runtime-audit.ts lists it in KNOWN_WIRE_GAPS. The proto:field-numbers sweep exists because it proves the question is answered exhaustively rather than by a hand-kept list — it found this one and nothing else, which is the useful result.', + review: '2026-10-01', + // The field numbers, not just the name. Any finding mentioning the field was + // accepted, so a rejection of it, a corrupted value, or a decode difference + // with another cause was absorbed by the entry that describes a renumbering. + // The field numbers where the finding carries them, and the documented + // outcome where it does not. `proto:field-numbers` reports `field 115` + // against `field 114`, and requiring that stops a rejection or a corrupted + // value on the same field being absorbed. The other two views never see a + // number — the name sweep reports the key list, `wire:upstream-readable` + // two decodes of the same bytes — so there the claim is that the bridge + // read nothing where upstream read the field. + when: divergence => { + if (!text(divergence.input).includes('pollResultSnapshotMessageV3')) return false + if (divergence.target === 'proto:field-numbers') { + return text(divergence.local).includes('115') && text(divergence.upstream).includes('114') + } + // The other views never carry a number. The name sweep reports the key list + // and `proto:decode-parity` two decodes of the same bytes, where the field + // number disagreement shows as the key being present on exactly one side. + // Removing it has to close the gap completely. + if (text(divergence.local).includes('')) return true + if (text(divergence.local).includes('')) return true + // The byte-level targets render the wire as `field:wireType:value`, and + // the renumbering cannot be pinned from that rendering: the renderer + // descends into a nested message *by field number*, so upstream's 114 is + // parsed to `{4:0:0}` where the bridge's 115 stays raw hex — the same + // bytes spelled two ways, as a direct consequence of the renumbering being + // excused. Splitting on commas does not work either, since the commas + // inside a nested group split with it. + // + // A comment here used to say pinning this needed the target's own tooling + // and left it as a bare name match. It now has that: the target deletes + // the field, re-encodes on both sides, and tags whether the encodings then + // agree. If they do, the renumbering was the whole difference; a corrupted + // value or a second changed field survives the deletion and is not tagged. + if (typeof divergence.local === 'string' && typeof divergence.upstream === 'string') { + return ( + (divergence.detail ?? '').includes('[renumbering only') || + (divergence.detail ?? '').includes('; renumbering only') + ) + } + const mine = withoutKey(normalise(divergence.local), 'pollResultSnapshotMessageV3') + const theirs = withoutKey(normalise(divergence.upstream), 'pollResultSnapshotMessageV3') + return sameShape(mine, theirs) && !sameShape(normalise(divergence.local), normalise(divergence.upstream)) + } + }, + { + id: 'proto-wire-type-mismatch-ignored-upstream', + target: 'proto:mutation-agreement', + status: 'intended', + reason: + "protobufjs ignores the wire type of a field it recognises; the bridge honours it. Minimal case, verified directly: `0a 02 08 20` against SyncActionValue is field 1 (`optional int64 timestamp`) written as wire type 2, wrapping the legal `08 20`. protobufjs runs its generated `case 1: reader.int64()` regardless of the wire type, reads the length byte as the value, then meets the inner `08 20` at the next tag and overwrites it — so the wrapper is flattened away and it reports `timestamp: 32` at any nesting depth. The bridge sees a varint field arriving as length-delimited, treats it as unknown, and reports `{}`. The spec is on the bridge's side: a wire type that does not match the declared one makes the field unknown, and silently reinterpreting it is how a parser reads a value the sender never wrote. The nesting-bomb mutator reaches this on every path whose field 1 is not a message, which is most of them.", + review: '2027-02-01', + when: divergence => + // The exact mutator, not a substring of the chain. `mutate` records + // `nesting-bomb → flip-bit`, so a substring test excused whatever the + // *second* mutator produced merely because a nesting bomb ran first. + (divergence.input as { mutator?: unknown } | undefined)?.mutator === 'nesting-bomb' && + // Narrow to the direction the reason argues: the bridge decoded an empty + // message, upstream decoded a non-empty one. The reverse, and any + // disagreement over a field both sides read, is not this and must still + // be reported. `plainObject` rather than a truthiness check so a `null` + // or a string from either side falls through instead of being excused. + plainObject(normalise(divergence.local))?.length === 0 && + (plainObject(normalise(divergence.upstream))?.length ?? 0) > 0 + }, + { + id: 'proto-repeated-scalars-unpacked', + target: 'proto:field-packing', + status: 'open', + reason: + 'Repeated scalar and enum fields are written unpacked by the bridge (08 00 08 01) and packed by protobufjs (0a 02 00 01). proto3 defaults to packed and every decoder must accept both, so no data is lost — but the wire bytes differ for every repeated scalar the library sends, which rules out byte-identical comparison against upstream and is worth a deliberate decision rather than a discovery.', + review: '2026-11-01' + }, + { + id: 'proto-field-order-follows-input-keys', + target: 'proto:field-order', + status: 'intended', + reason: + 'The bridge emits fields in the order the keys appear on the object it was given; protobufjs emits them in schema declaration order. Protobuf explicitly permits any order and requires decoders to accept it, so this is a representation difference with no observable consequence for a conforming peer.', + review: '2027-02-01' + }, + { + id: 'proto-unknown-type-dropped', + target: /^proto:(unknown-type-dropped|type-coverage)$/u, + status: 'open', + when: divergence => namesUnknownCodecType(divergence.input), + reason: + 'The bridge codec does not implement every message type the upstream protos declare (BotAvatarMetadata at the time of writing), and a field holding one is silently omitted rather than reported: MessageContextInfo{botMetadata:{avatarMetadata:{}}} encodes to 3a00 instead of 3a020a00. ALREADY TRACKED: BotAvatarMetadata is in KNOWN_UNSUPPORTED_CODECS and its fields in KNOWN_WIRE_GAPS in scripts/compatibility/proto-runtime-audit.ts. The unknown-type set here is probed at runtime rather than listed, so this entry stops matching by itself once the bridge implements them.', + review: '2026-10-01' + }, + { + id: 'proto-empty-string-for-numeric-field', + target: /^proto:/u, + status: 'open', + reason: + 'Given an empty string where the schema declares a 64-bit integer, the bridge coerces to 0 and protobufjs throws "empty string" — it routes 64-bit fields through Long.fromString, which rejects it. 32-bit fields are not affected: both sides coerce to 0 there, which is why the generator seeds the empty string into the 64-bit pools only. Same shape as the toNumber difference: baileyrs is the tolerant one. Tolerant is defensible, but it means a caller\'s type error is silently encoded as a real value instead of surfacing.', + review: '2026-11-01', + // The upstream error *and* what the bridge actually wrote. Keyed on the + // message alone, a regression that encoded the empty string as a nonzero + // value, or dropped the field, stayed green under a "coerces to 0" + // exception. A zero-valued 64-bit field encodes as the tag followed by a + // single `00`, or is omitted entirely when the field has no explicit + // presence — so those are the two outputs this accepts. + when: divergence => text(divergence.upstream).includes('empty string') && coercedAnEmptyString(divergence) + }, + { + id: 'poll-vote-aggregation-order', + target: 'pure:getAggregateVotesInPollMessage', + status: 'open', + // Ordering only. Now that generated votes actually hash to the declared + // options, an unqualified entry would excuse a voter bucketed under the + // wrong option, a dropped voter or a renamed option — all of which change + // the multiset, and none of which this entry has ever claimed. + when: divergence => samePollAggregate(normalise(divergence.local), normalise(divergence.upstream)), + reason: + 'The two aggregate the same votes into the same buckets but emit the option/voter entries in a different order. Consumers that index into the returned array rather than looking options up by name see different results.', + review: '2026-11-01' + } +] + +const matchesTarget = (entry: KnownDivergence, target: string): boolean => + typeof entry.target === 'string' ? entry.target === target : entry.target.test(target) + +export interface AllowlistOutcome { + /** Divergences with no matching entry: these fail the run. */ + readonly unexcused: readonly Divergence[] + /** Ids that matched at least one divergence. */ + readonly used: readonly string[] + /** Ids of matched entries still marked `open` — reported on every run. */ + readonly openHits: readonly string[] + /** Entries whose `review` date has passed. */ + readonly expired: readonly KnownDivergence[] +} + +export const applyAllowlist = ( + divergences: readonly Divergence[], + now: Date, + registry: readonly KnownDivergence[] = KNOWN_DIVERGENCES +): AllowlistOutcome => { + const unexcused: Divergence[] = [] + const used = new Set() + const openHits = new Set() + + for (const divergence of divergences) { + const entry = registry.find( + candidate => matchesTarget(candidate, divergence.target) && (candidate.when?.(divergence) ?? true) + ) + if (entry) { + used.add(entry.id) + if (entry.status === 'open') openHits.add(entry.id) + } else unexcused.push(divergence) + } + + const today = now.toISOString().slice(0, 10) + const expired = registry.filter(entry => entry.review < today) + + return { unexcused, used: [...used], openHits: [...openHits], expired } +} + +/** Registry ids that matched nothing across a whole run — candidates for deletion. */ +export const staleEntries = ( + used: readonly string[], + registry: readonly KnownDivergence[] = KNOWN_DIVERGENCES +): readonly KnownDivergence[] => registry.filter(entry => !used.includes(entry.id)) diff --git a/src/__fuzz__/harness/random.ts b/src/__fuzz__/harness/random.ts new file mode 100644 index 00000000..8640938e --- /dev/null +++ b/src/__fuzz__/harness/random.ts @@ -0,0 +1,114 @@ +/** + * Deterministic PRNG for the fuzz suite. + * + * Nothing in `src/__fuzz__` may reach for `Math.random`: a divergence nobody can + * replay is a divergence nobody can fix. Every run reports the seed it used and + * `FUZZ_SEED=` reproduces the identical input stream, so a nightly failure + * turns into a local one-liner. + */ + +/** Hashes an arbitrary seed string into the four words sfc32 needs. */ +const seedWords = (seed: string): [number, number, number, number] => { + let h = 2_166_136_261 >>> 0 + const words: number[] = [] + for (let round = 0; round < 4; round++) { + for (let index = 0; index < seed.length; index++) { + h ^= seed.charCodeAt(index) + h = Math.imul(h, 16_777_619) + h ^= h >>> 13 + } + h = Math.imul(h ^ round, 2_654_435_761) + words.push(h >>> 0 || 0x9e37_79b9) + } + return [words[0]!, words[1]!, words[2]!, words[3]!] +} + +export interface Random { + /** Uniform float in [0, 1). */ + next(): number + /** Uniform integer in [min, max], inclusive on both ends. */ + int(min: number, max: number): number + /** Uniform integer in [0, bound). Returns 0 when `bound <= 0`. */ + below(bound: number): number + /** True with probability `probability` (default 0.5). */ + bool(probability?: number): boolean + /** Uniform element of a non-empty list. */ + pick(items: readonly T[]): T + /** Picks by relative weight; entries with weight <= 0 are never chosen. */ + weighted(entries: readonly (readonly [weight: number, value: T])[]): T + /** A copy of `items` in random order. */ + shuffle(items: readonly T[]): T[] + /** `count` random bytes. */ + bytes(count: number): Uint8Array + /** + * An independent stream derived from this one. Used to give each generated + * sub-value its own deterministic stream without ordering coupling. + */ + fork(label: string): Random + /** The seed this stream was built from, for failure reports. */ + readonly seed: string +} + +/** sfc32 — small, fast, no dependencies, and good enough for input generation. */ +export const makeRandom = (seed: string): Random => { + let [a, b, c, d] = seedWords(seed) + + const next = (): number => { + a >>>= 0 + b >>>= 0 + c >>>= 0 + d >>>= 0 + let t = (a + b) | 0 + a = b ^ (b >>> 9) + b = (c + (c << 3)) | 0 + c = (c << 21) | (c >>> 11) + d = (d + 1) | 0 + t = (t + d) | 0 + c = (c + t) | 0 + return (t >>> 0) / 4_294_967_296 + } + + // Discard the first outputs: sfc32 needs a short warm-up before the low bits + // of a weak seed stop showing through. + for (let index = 0; index < 12; index++) next() + + const random: Random = { + seed, + next, + below: bound => (bound <= 0 ? 0 : Math.floor(next() * bound)), + int: (min, max) => (max <= min ? min : min + Math.floor(next() * (max - min + 1))), + bool: (probability = 0.5) => next() < probability, + pick: items => { + if (items.length === 0) throw new Error('Random.pick: empty list') + return items[Math.floor(next() * items.length)]! + }, + weighted: entries => { + const total = entries.reduce((sum, [weight]) => sum + Math.max(0, weight), 0) + if (total <= 0) throw new Error('Random.weighted: no entry carries positive weight') + let threshold = next() * total + for (const [weight, value] of entries) { + threshold -= Math.max(0, weight) + if (threshold < 0) return value + } + return entries[entries.length - 1]![1] + }, + shuffle: items => { + const copy = [...items] + for (let index = copy.length - 1; index > 0; index--) { + const target = Math.floor(next() * (index + 1)) + const swap = copy[index]! + copy[index] = copy[target]! + copy[target] = swap + } + return copy + }, + bytes: count => { + const out = new Uint8Array(Math.max(0, count)) + for (let index = 0; index < out.length; index++) out[index] = Math.floor(next() * 256) + return out + }, + fork: label => makeRandom(`${seed}:${label}:${Math.floor(next() * 4_294_967_296)}`) + } + + return random +} diff --git a/src/__fuzz__/harness/runner.ts b/src/__fuzz__/harness/runner.ts new file mode 100644 index 00000000..f6006ad4 --- /dev/null +++ b/src/__fuzz__/harness/runner.ts @@ -0,0 +1,616 @@ +/** + * The property runner every fuzzer in this directory goes through. + * + * It owns the parts that decide whether a fuzz suite is useful or ignored: + * a fixed seed by default (so `npm test` never fails by luck), a bounded budget + * (so it stays a test and not a job), shrinking before reporting, the corpus + * replayed ahead of fresh input, and the known-divergence allowlist applied + * before anything is called a failure. + * + * Environment: + * FUZZ_SEED seed string (default `baileyrs-fuzz-v1`) + * FUZZ_RUNS iterations per target; ignored by exhaustive sweeps + * FUZZ_MODE `smoke` (default) or `deep` + * FUZZ_DEEP_FACTOR multiplier applied to budgets in deep mode (default 25) + * FUZZ_TIME_BUDGET_MS per-target wall-clock ceiling + * FUZZ_ONLY substring filter over target names, for triage + * FUZZ_RECORD `1` to append minimised failures to the corpus + * FUZZ_STRICT_ALLOWLIST `1` to fail on expired allowlist entries + * FUZZ_REPORT_DIR directory to write per-target JSON reports into + */ + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { applyAllowlist, KNOWN_DIVERGENCES, type Divergence } from './divergence.ts' +import { loadCorpus, recordCorpus, corpusSlug } from './corpus.ts' +import { makeRandom, type Random } from './random.ts' +import { shrink } from './shrink.ts' + +const environment = (name: string): string | undefined => { + const value = process.env[name] + return value === undefined || value === '' ? undefined : value +} + +export const FUZZ_SEED = environment('FUZZ_SEED') ?? 'baileyrs-fuzz-v1' +/** + * Validated, not coerced. Anything that was not exactly `deep` used to become + * `smoke`, and the manual workflow takes the mode as free text — so a typo like + * `depe` finished green having run roughly 25 times fewer cases than the + * operator asked for, which is the most expensive kind of quiet pass. + */ +const readMode = (): 'smoke' | 'deep' => { + const raw = environment('FUZZ_MODE') + if (raw === undefined || raw === 'smoke') return 'smoke' + if (raw === 'deep') return 'deep' + throw new Error(`fuzz: FUZZ_MODE=${JSON.stringify(raw)} is not "smoke" or "deep"`) +} + +export const FUZZ_MODE = readMode() + +/** + * Parses a numeric override, refusing anything that is not a positive finite + * number. + * + * `Number('all')` is `NaN`, and `index < NaN` is false on the first iteration — + * so a typo in FUZZ_RUNS would make every target generate zero inputs and report + * a pass. A fuzz suite that silently checks nothing is worse than no suite, so + * this fails loudly instead. + */ +const positiveNumber = (name: string, fallback?: number): number | undefined => { + const raw = environment(name) + if (raw === undefined) return fallback + const parsed = Number(raw) + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`fuzz: ${name}=${JSON.stringify(raw)} is not a positive finite number`) + } + return parsed +} + +const deepFactor = positiveNumber('FUZZ_DEEP_FACTOR', 25)! +const runsOverride = positiveNumber('FUZZ_RUNS') +const timeBudgetOverride = positiveNumber('FUZZ_TIME_BUDGET_MS') +const onlyFilter = environment('FUZZ_ONLY') +const recording = environment('FUZZ_RECORD') === '1' +const strictAllowlist = environment('FUZZ_STRICT_ALLOWLIST') === '1' +const reportDirectory = environment('FUZZ_REPORT_DIR') + +/** + * Expiries already reported in this process. + * + * The expired set is global to the registry, not per target, so without this + * every target repeats every expiry. + */ +const reportedExpiries = new Set() + +/** Wall-clock ceiling per target: keeps `npm test` predictable on a busy runner. */ +const defaultTimeBudgetMs = FUZZ_MODE === 'deep' ? 180_000 : 6_000 + +export interface FuzzOptions { + /** Stable identity, e.g. `jid:jidDecode`. Also names the corpus file. */ + readonly target: string + /** Builds one input. Must consume `random` only — never `Math.random`. */ + readonly generate: (random: Random) => T + /** + * Inspects one input. Return the differences found (empty when the input is + * fine); throwing is also a finding, reported as a crash. + */ + readonly check: ( + input: T + ) => readonly Divergence[] | Divergence | void | Promise + /** Iterations in smoke mode; deep mode multiplies this. Default 150. */ + readonly runs?: number + /** Set false when the input is already minimal (raw byte strings, mostly). */ + readonly shrinkFailures?: boolean + /** + * Set false when the input is an argument list whose length is part of the + * call being tested — shrinking would otherwise report "called with fewer + * arguments", which is a different question. + */ + readonly shrinkRoot?: boolean + /** + * Reports an input whose check *finished* but took longer than this. Catches + * the algorithmic blow-ups (deep nesting, quadratic parsing) that never + * surface as a wrong answer, only as a stalled socket. + * + * It is a post-hoc measurement, not a ceiling: the elapsed time is read after + * `check` returns, so an input that never returns — a synchronous WASM loop, + * an unbounded recursion inside the runtime — is not reported here at all. + * Interrupting one would mean running every check in a worker or a + * subprocess, which costs a WASM instantiation per input and is a different + * harness. What bounds that case instead is `--test-timeout` on the fuzz + * scripts: `node --test` runs each file in its own process and the parent + * kills one that stops reporting, so a synchronous spin fails the run with + * `test timed out after Nms` naming the file, rather than sitting there until + * the job's own 45-minute ceiling. Verified against a `for(;;){}` test — the + * timer is in the parent, so a blocked child event loop does not defeat it. + */ + readonly slowMs?: number + /** + * Marks a finite sweep that must run to completion. + * + * A random target that stops early has simply sampled less. A sweep that stops + * early has answered a different question than the one it claims to answer — + * "every field is named correctly" becomes "the first 747 are", while still + * reporting a pass. The time budget is therefore not applied to these. + */ + readonly exhaustive?: boolean +} + +export interface FuzzReport { + readonly target: string + readonly seed: string + readonly mode: string + readonly runs: number + readonly corpusReplayed: number + readonly excused: number + /** Registry ids that excused at least one finding, so stale entries can be found across targets. */ + readonly excusedBy: readonly string[] + /** Of those, the ones still marked `open`. */ + readonly openFindings: readonly string[] + /** Set when the time budget cut the run short, so a partial pass is never silent. */ + readonly truncated?: { readonly ran: number; readonly planned: number } + readonly findings: readonly Divergence[] + /** + * Failures that are not divergences: a check or generator that threw, or an + * expired allowlist entry under `FUZZ_STRICT_ALLOWLIST`. + * + * These belong in the report and not only in the thrown error. A crash with no + * divergence used to write `findings: []` and then throw, so the summariser saw + * a clean run, filed no issue, and printed "0 findings" for a run that had + * actually failed. The job went red with a summary saying nothing was wrong. + */ + readonly crashes: readonly string[] + /** + * How many crashes actually happened, which is not `crashes.length`. + * + * The details are capped and deduplicated — see `recordCrash` — so a systemic + * failure stores a handful of entries rather than one per input. The count is + * the number worth reporting, and the summariser prints this rather than the + * length of the array it happens to have been handed. + */ + readonly crashCount: number +} + +/** + * Renders a generated value for a failure report without ever throwing. + * + * Generated input deliberately contains values that break naive formatting — + * `__proto__` keys that swap an object's prototype, revoked proxies, getters + * that throw. A reporter that dies on the input hides the very finding it was + * called to describe, so every step here has a fallback. + */ +const preview = (value: unknown, limit = 900): string => { + const seen = new WeakSet() + let text: string | undefined + try { + text = JSON.stringify( + value, + (_key, nested: unknown) => { + if (typeof nested === 'bigint') return `${nested.toString()}n` + if (nested instanceof Uint8Array) + return `` + if (typeof nested === 'object' && nested !== null) { + if (seen.has(nested)) return '' + seen.add(nested) + } + if (typeof nested === 'function') return `` + if (typeof nested === 'symbol') return nested.toString() + return nested + }, + 1 + ) + } catch { + text = undefined + } + if (typeof text !== 'string') { + try { + text = String(value) + } catch { + text = `` + } + } + return text.length > limit ? `${text.slice(0, limit)}… (${text.length} chars)` : text +} + +/** + * A value the reader can paste into a shell and get back verbatim. + * + * `FUZZ_SEED` is free-form — the nightly workflow takes it as a text input — so + * an unquoted hint is not the command that ran: a seed of `nightly run` makes + * the shell treat `run` as the command instead of `npm`, and a `;` or `$(…)` + * turns a copied reproduction into something else entirely. Single quotes + * rather than JSON, because double quotes still expand `$`, backticks and `\`. + * + * Left bare when nothing needs quoting, so the ordinary hint stays readable. + */ +const shellQuote = (value: string): string => + /^[\w.:@/+=-]+$/u.test(value) ? value : `'${value.replaceAll("'", String.raw`'\''`)}'` + +/** + * The command that reproduces a finding — which has to actually run. + * + * It printed `FUZZ_RUNS=as-configured` when there was no override, and + * `positiveNumber` rejects that at module load: the advertised reproduction + * failed before reaching a single target. The variable is now omitted unless + * there is a real value to pass, and omitted for exhaustive targets, which + * ignore it. + */ +/** How many distinct crash details one target keeps. The rest are counted only. */ +const CRASH_DETAIL_LIMIT = 5 + +const replayHint = (target: string, exhaustive: boolean): string => { + const runs = exhaustive || runsOverride === undefined ? '' : ` FUZZ_RUNS=${runsOverride}` + // The mode too. Deep multiplies every target's run count, so a finding only the + // deep budget reaches does not reproduce from a hint that silently drops back + // to smoke — which is what `npm test` runs. + const mode = FUZZ_MODE === 'deep' ? ' FUZZ_MODE=deep' : '' + return `FUZZ_SEED=${shellQuote(FUZZ_SEED)}${mode} FUZZ_ONLY=${shellQuote(target)}${runs} npm test` +} + +const describeFinding = (finding: Divergence, index: number): string => + [ + ` [${index + 1}] ${finding.target}${finding.detail ? ` — ${finding.detail}` : ''}`, + ` input ${preview(finding.input)}`, + ` baileyrs ${preview(finding.local)}`, + ` baileys ${preview(finding.upstream)}` + ].join('\n') + +const asList = (result: readonly Divergence[] | Divergence | void): readonly Divergence[] => { + if (!result) return [] + return Array.isArray(result) ? result : [result as Divergence] +} + +/** + * A structure `JSON.stringify` cannot be made to throw on. + * + * A replacer is not enough: `JSON.stringify` reads `toJSON` and then every + * property itself, so a value whose getters throw takes the call down before + * the replacer ever sees it. `argument-boundary.fuzz.test.ts` generates exactly + * that on purpose — a proxy whose `get` trap throws — and when one reached a + * finding, `writeReport` threw, which ran *before* the readable error below and + * so lost the JSON report and the divergence description together. Precisely + * when a generated case had found a boundary regression. + * + * So every read is guarded and every failure becomes a marker in place, leaving + * the rest of the report intact. + */ +const reportSafe = (value: unknown, seen = new WeakSet(), depth = 0): unknown => { + if (depth > 12) return '' + try { + if (typeof value === 'bigint') return value.toString() + if (typeof value === 'function') return `` + if (typeof value === 'symbol') return value.toString() + if (value === null || typeof value !== 'object') return value + if (seen.has(value)) return '' + seen.add(value) + if (value instanceof Uint8Array) + return `` + if (Array.isArray(value)) return value.map(item => reportSafe(item, seen, depth + 1)) + const out: Record = {} + for (const key of Object.keys(value)) { + try { + out[key] = reportSafe((value as Record)[key], seen, depth + 1) + } catch { + out[key] = '' + } + } + return out + } catch { + // `Object.keys`, `instanceof` and the typeof checks all reach traps a + // hostile object may define. Whatever it was, it is not worth a lost report. + return `` + } +} + +const writeReport = (report: FuzzReport): string | undefined => { + if (!reportDirectory) return undefined + try { + mkdirSync(reportDirectory, { recursive: true }) + writeFileSync( + join(reportDirectory, `${corpusSlug(report.target)}.json`), + `${JSON.stringify(reportSafe(report), null, '\t')}\n` + ) + return undefined + } catch (error) { + // A report that cannot be written must not replace the findings it was + // describing. Said out loud rather than swallowed, so a systematically + // failing write — a full disk, a bad FUZZ_REPORT_DIR — is visible. + const message = `could not write the report for ${report.target}: ${(error as Error)?.message}` + console.error(`fuzz: ${message}`) + return message + } +} + +/** + * Runs one property to exhaustion of its budget and throws a single readable + * error describing every unexcused difference it found. + */ +export const fuzz = async (options: FuzzOptions): Promise => { + const { target, generate, check } = options + + const baseRuns = options.runs ?? 150 + // An exhaustive sweep answers a finite question, so FUZZ_RUNS does not apply: + // honouring it would turn "every field is named correctly" into "the first N + // are" while still reporting a pass. + const runs = options.exhaustive + ? baseRuns + : (runsOverride ?? (FUZZ_MODE === 'deep' ? Math.ceil(baseRuns * deepFactor) : baseRuns)) + const timeBudgetMs = timeBudgetOverride ?? defaultTimeBudgetMs + const shrinkFailures = options.shrinkFailures ?? true + + const skipped = onlyFilter !== undefined && !target.includes(onlyFilter) + const corpus = skipped ? [] : loadCorpus(target) + + const findings: Divergence[] = [] + const crashes: string[] = [] + let excused = 0 + let executed = 0 + + /** + * Records a crash, bounded. + * + * A check that starts throwing throws for *every* input, and a deep run gives + * the mutation target 10,000 of them. Storing a full `preview(input)` per + * iteration made a systemic regression expensive twice over — the array grows + * without bound in memory, and every entry is then interpolated into the thrown + * test error, which is what reaches the Actions log. The most useful part of + * that output, the summary the aggregator caps, arrives after thousands of + * near-identical blocks. + * + * Deduplicated on the throw itself, since that is what makes two crashes the + * same defect, and capped at five distinct ones. `preview` is only called for + * an entry that will be kept, so the cost stops with the storage. + */ + const seenCrashes = new Set() + let crashCount = 0 + const recordCrash = (signature: string, render: () => string): void => { + crashCount++ + if (seenCrashes.has(signature) || seenCrashes.size >= CRASH_DETAIL_LIMIT) return + seenCrashes.add(signature) + crashes.push(render()) + } + + /** + * `reportCrash` is false for shrink candidates and the minimised re-check. + * + * Shrinking proposes values the generator would never produce — a required + * field dropped, an object emptied — and a throw on one of those is the + * shrinker exploring, not the library failing. Recording it would fail the + * target on an input it was never given, and crashes bypass the allowlist, so + * a known divergence could not excuse it either. + */ + const runOne = async (input: T, origin: string, reportCrash = true): Promise => { + const startedAt = performance.now() + let produced: readonly Divergence[] + try { + produced = asList(await check(input)) + } catch (error) { + if (!reportCrash) return [] + const failure = error as Error + const threw = `${failure?.name ?? 'Error'}: ${failure?.message ?? String(error)}` + recordCrash( + threw, + () => ` [crash] ${target} (${origin})\n input ${preview(input)}\n threw ${threw}` + ) + return [] + } + const elapsed = performance.now() - startedAt + if (options.slowMs !== undefined && elapsed > options.slowMs) { + return [ + ...produced, + { + target: `${target}#slow`, + input, + local: `${elapsed.toFixed(0)}ms`, + upstream: `<= ${options.slowMs}ms`, + detail: 'a single input took longer than the target expects to check' + } + ] + } + return produced + } + + // The corpus first: past finds are cheap and must never silently come back. + for (const entry of corpus) { + const produced = await runOne(entry.input as T, `corpus: ${entry.note}`) + executed++ + findings.push(...produced) + } + + let truncatedAt: number | undefined + if (!skipped) { + const deadline = performance.now() + (options.exhaustive ? Number.POSITIVE_INFINITY : timeBudgetMs) + const random = makeRandom(`${FUZZ_SEED}:${target}`) + for (let index = 0; index < runs; index++) { + if (performance.now() > deadline) { + truncatedAt = index + break + } + let input: T + try { + input = generate(random) + } catch (error) { + crashCount++ + crashes.push(` [crash] ${target} generator threw: ${(error as Error).message}`) + break + } + const produced = await runOne(input, `run ${index}`) + executed++ + if (produced.length === 0) continue + + // Minimise against "still produces a finding *of one of the original + // classes*", not merely "still produces a finding". + // + // Without the class constraint, shrinking can walk off onto a different + // defect: reducing a numeric string to '' or dropping a field turns an + // unexcused regression into an already-allowlisted empty-string or + // field-omission divergence, and since the minimised findings replace the + // original ones below, the regression is then reported — and excused — as + // the known difference. The allowlist discipline depends on this. + // The target name alone is too coarse to be the class: two different + // defects share `proto:decode-parity`, one of them allowlisted. Shrinking a + // new regression into the known SAFE_INTEGER case would keep the target and + // lose the bug. So excusability is carried too — a finding the allowlist + // does not cover may only ever be minimised into another one it does not + // cover. + // Every class, not any one of them. A check may return several findings — + // `proto:decode-parity` reports one per encoder source — and requiring + // only that *some* finding of an original class survive let shrinking + // discard a second, independent defect: the minimised findings replace + // the whole original set below, so the dropped one was never reported and + // `FUZZ_RECORD` froze an input that no longer reproduces it. + // The class carries the detail as well as the target and the allowlist + // verdict, and the count as well as the set. A check can emit two findings + // with the same target and status — `proto:decode-parity` reports one per + // encoder source — and a Set collapsed them, so a candidate keeping just + // one satisfied the check and the other defect vanished from the report + // and from the recorded corpus input. + const classOf = (finding: Divergence): string => + `${finding.target}\u0000${finding.detail ?? ''}\u0000${applyAllowlist([finding], new Date()).unexcused.length > 0 ? 'open' : 'excused'}` + const tally = (found: readonly Divergence[]): Map => { + const counts = new Map() + // Once per finding: `classOf` runs `applyAllowlist`, which scans the whole + // registry and evaluates `when` predicates that recurse over normalised + // values — and `tally` runs on every shrink candidate, up to 1200 deep. + for (const finding of found) { + const id = classOf(finding) + counts.set(id, (counts.get(id) ?? 0) + 1) + } + return counts + } + const originalCounts = tally(produced) + const preservesClass = (found: readonly Divergence[]): boolean => { + const counts = tally(found) + return [...originalCounts].every(([id, count]) => (counts.get(id) ?? 0) >= count) + } + + // Shrinking gets whatever is left of the target's budget, and never less + // than the floor: the count bounds evaluations, not time, so a slow + // reproducer found near the deadline could otherwise spend twenty minutes + // minimising after the budget expired — long enough to reach the parent + // test timeout and lose the report the nightly exists to produce. The + // floor is there because a finding on the last input still deserves to be + // minimised; it just does not deserve unbounded time. + const shrinkFloorMs = FUZZ_MODE === 'deep' ? 15_000 : 5_000 + const shrinkDeadline = performance.now() + Math.max(deadline - performance.now(), shrinkFloorMs) + const minimised = shrinkFailures + ? await shrink(input, async candidate => preservesClass(await runOne(candidate, 'shrink', false)), { + maxEvaluations: FUZZ_MODE === 'deep' ? 1_200 : 300, + deadline: shrinkDeadline, + shrinkRoot: options.shrinkRoot ?? true + }) + : input + + const rerun = await runOne(minimised, 'minimised', false) + const minimisedFindings = rerun.filter(finding => originalCounts.has(classOf(finding))) + // Keep the original when minimisation did not hold the class. Reporting a + // smaller input that no longer shows the defect is worse than a large one + // that does. + const held = preservesClass(rerun) + findings.push(...(held ? minimisedFindings : produced)) + + if (recording) { + // The same input the findings above describe. Freezing the minimised one + // after falling back to the original would commit a corpus entry that + // replays clean — a regression test for nothing, and worse than none + // because it looks like coverage. + recordCorpus(target, { note: `seed ${FUZZ_SEED}, run ${index}`, input: held ? minimised : input }) + } + } + } + + const outcome = applyAllowlist(findings, new Date()) + excused = findings.length - outcome.unexcused.length + + // Never let a budget cap pass for coverage. A run that checked 747 of 1734 + // inputs and printed nothing reads exactly like one that checked them all. + // + // A warning here rather than a finding, because the two callers want different + // things: the PR smoke run has a 6s budget and truncates on a busy machine, so + // failing on it would be flaky. The count also goes into the report, and + // `scripts/fuzz/report.ts` fails the nightly on it under `--fail-on-stale` — + // where the budget is 180s per target and truncation means something is slower + // than it was. + if (truncatedAt !== undefined) { + console.warn( + `fuzz: ${target} stopped at ${truncatedAt}/${runs} inputs after ${timeBudgetMs}ms — raise FUZZ_TIME_BUDGET_MS to cover the rest` + ) + } + + // Open findings are printed on every single run. They are recorded so the + // suite stays green and does not re-report them as news, never so they can be + // forgotten — an allowlist you cannot see is indistinguishable from a bug. + for (const id of outcome.openHits) { + const entry = KNOWN_DIVERGENCES.find(candidate => candidate.id === id) + console.warn(`fuzz: open finding "${id}" still reproduces on ${target} (review by ${entry?.review ?? 'unknown'})`) + } + + // Once per process, not once per target. `outcome.expired` is the whole global + // registry's expired set, so pushing it into every target's crashes turned nine + // expired entries into well over a thousand duplicate records across the run — + // enough to bury the actual findings and to push the nightly issue body past + // what GitHub will accept. `report.ts` lists each expired entry once anyway. + for (const entry of outcome.expired) { + if (reportedExpiries.has(entry.id)) continue + reportedExpiries.add(entry.id) + const message = `fuzz: known-divergence "${entry.id}" was due for review on ${entry.review} — re-argue it or delete it` + if (strictAllowlist) { + crashCount++ + crashes.push(` [allowlist] ${message}`) + } else console.warn(message) + } + + // One line for everything the cap held back, so the count in the report and the + // count a reader can see never disagree. + if (crashCount > crashes.length) { + crashes.push( + ` [crash] …and ${crashCount - crashes.length} more (repeats of the above, or past the ${CRASH_DETAIL_LIMIT}-detail cap)` + ) + } + + // Written here rather than earlier so `crashes` is complete: the report is what + // the summariser reads, and a report that omits the crashes describes a run + // that failed as one that passed. + const report: FuzzReport = { + target, + seed: FUZZ_SEED, + mode: FUZZ_MODE, + runs: executed, + corpusReplayed: corpus.length, + excused, + excusedBy: outcome.used, + openFindings: outcome.openHits, + truncated: truncatedAt === undefined ? undefined : { ran: truncatedAt, planned: runs }, + findings: outcome.unexcused, + crashes, + crashCount + } + // A failed write is a failed target, not a logged inconvenience. + // + // The aggregator decides whether the run can answer "which registry entries + // went unused" by looking at the reports that arrived. A write that fails part + // way through a run — the report volume filling during a deep run is the + // realistic case — leaves a non-empty but incomplete set that looks complete, + // and entries used only by the missing targets are then recommended for + // deletion. Failing here means the fuzz step goes red, the summariser is + // invoked with `--run-failed`, and stale-entry enforcement stands down. + const writeFailure = writeReport(report) + if (writeFailure !== undefined) { + crashCount++ + crashes.push(`fuzz: ${writeFailure}`) + } + + if (outcome.unexcused.length > 0 || crashCount > 0) { + const lines = [ + `fuzz found ${outcome.unexcused.length} divergence(s) and ${crashCount} crash(es) on ${target}`, + ` seed ${FUZZ_SEED} (mode ${FUZZ_MODE}, ${executed} inputs, ${corpus.length} from corpus)`, + ` replay ${replayHint(target, options.exhaustive === true)}`, + ` record add FUZZ_RECORD=1 to freeze the minimised input into the corpus`, + ...outcome.unexcused.map((finding, index) => describeFinding(finding, index)), + ...crashes + ] + throw new Error(lines.join('\n')) + } + + return report +} diff --git a/src/__fuzz__/harness/send-path.ts b/src/__fuzz__/harness/send-path.ts new file mode 100644 index 00000000..ae07c893 --- /dev/null +++ b/src/__fuzz__/harness/send-path.ts @@ -0,0 +1,89 @@ +/** + * The real send path, with a bridge client that captures instead of sending. + * + * `scripts/compatibility/wire-fidelity-core.ts` does the same thing for its fixed + * case list. This is a separate, self-contained copy rather than an import + * because `src/**` is its own TypeScript project with `rootDir: ./src` — reaching + * into `scripts/` from here would not compile, and `check-layer-boundaries` + * exists to keep exactly this kind of reach from creeping in. + * + * What it gives the fuzzers is the thing no unit test can: the bytes + * `relayMessage` actually hands the bridge, for a message nobody wrote by hand. + */ + +import { EventEmitter } from 'node:events' +import type { WasmWhatsAppClient } from '@oxidezap/whatsapp-rust-bridge' +import { makeMessageMethods } from '../../Socket/messages.ts' +import type { SocketContext } from '../../Socket/types.ts' +import type { WAProto } from '../../Types/index.ts' +import type { ILogger } from '../../Utils/logger.ts' + +const silentLogger = { + level: 'silent', + child: () => silentLogger, + trace: () => undefined, + debug: () => undefined, + info: () => undefined, + warn: () => undefined, + error: () => undefined +} as unknown as ILogger + +const capturingContext = (captured: Uint8Array[]): SocketContext => { + // `relayMessage` dispatches on a relay plan: a normal send, a retransmission, + // or a status broadcast. The generator produces `status@broadcast` jids, so a + // client with only the first method would fail with "not a function" instead + // of yielding bytes — a harness gap that reads exactly like a send-path bug. + const client = { + relayMessageBytesWithOptions: async (_jid: string, bytes: Uint8Array, messageId: string) => { + captured.push(bytes) + return messageId + }, + sendStatusMessageBytesWithOptions: async (bytes: Uint8Array, _recipients: unknown, messageId: string) => { + captured.push(bytes) + return messageId + }, + retransmitMessageBytes: async (_jid: string, bytes: Uint8Array, messageId?: string) => { + captured.push(bytes) + return messageId + } + } as unknown as WasmWhatsAppClient + + return { + ev: Object.assign(new EventEmitter(), { + createBufferedFunction: (work: (...args: Args) => Promise) => work + }), + logger: silentLogger, + fullConfig: { options: {}, emitOwnEvents: false }, + getUser: () => ({ id: '15550000000@s.whatsapp.net', lid: '100000000000000@lid' }), + getMe: () => ({ id: '15550000000@s.whatsapp.net', lid: '100000000000000@lid' }), + getClient: async () => client + } as unknown as SocketContext +} + +export interface RelayOptions { + readonly jid?: string + readonly messageId?: string +} + +/** Pushes a message through `relayMessage` and returns the bytes the bridge received. */ +export const relayedBytes = async ( + message: Record, + options: RelayOptions = {} +): Promise => { + const captured: Uint8Array[] = [] + const context = capturingContext(captured) + await makeMessageMethods(context).relayMessage( + options.jid ?? '120363000000000000@g.us', + structuredClone(message) as WAProto.IMessage, + { messageId: options.messageId ?? '3EB0FUZZ0000000000' } + ) + // Exactly one, not the first of however many. Returning `captured[0]` and + // ignoring the rest means a regression that sends the same message twice — + // or sends a second, malformed one after a valid first — passes all three + // wire-fidelity targets while users receive duplicates. + if (captured.length === 0) throw new Error('the send path handed no bytes to the bridge') + if (captured.length > 1) { + throw new Error(`the send path made ${captured.length} bridge calls for one message; expected exactly one`) + } + return captured[0]! +} diff --git a/src/__fuzz__/harness/shrink.ts b/src/__fuzz__/harness/shrink.ts new file mode 100644 index 00000000..a2d30985 --- /dev/null +++ b/src/__fuzz__/harness/shrink.ts @@ -0,0 +1,245 @@ +/** + * Structural shrinker. + * + * A 400-node generated message that diverges is not a bug report, it is a + * haystack. Every fuzzer here routes its failing input through `shrink` before + * reporting, so what lands in the console is the smallest value that still + * reproduces — usually two or three fields. + * + * The strategy is the standard greedy one: propose simpler candidates, keep the + * first that still fails, repeat until nothing simpler fails. + */ + +/** + * True when the candidate still reproduces the failure being minimised. + * + * Async is allowed so the same shrinker serves the socket-boundary fuzzer, whose + * every probe is a promise. + */ +export type StillFails = (candidate: T) => boolean | Promise + +export interface ShrinkOptions { + /** Upper bound on candidate evaluations, so shrinking cannot outlive the run. */ + maxEvaluations?: number + /** + * Wall-clock ceiling, as a `performance.now()` reading to stop at. + * + * The evaluation count alone does not bound the time: it bounds the number of + * checks, and a check that reproduces a slow input costs whatever that input + * costs. A one-second reproducer against the deep budget's 1200 evaluations is + * twenty minutes of shrinking — long enough to hit the parent test timeout and + * lose the report entirely, which is the one outcome worse than a large + * unminimised input. + * + * Whatever has been found by the deadline is returned. Shrinking is greedy and + * monotone, so stopping early yields a partly-minimised input rather than a + * wrong one. + */ + deadline?: number + /** Upper bound on greedy passes over the value. */ + maxPasses?: number + /** + * Whether the root value itself may be replaced (default true). + * + * Set false when the root is an argument list: dropping an element there + * changes the call's arity, and "we called it with fewer arguments" is a + * different question from the one the fuzzer asked. Children still shrink. + */ + shrinkRoot?: boolean +} + +const isTypedBytes = (value: unknown): value is Uint8Array => value instanceof Uint8Array + +const isPlainObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) && !isTypedBytes(value) + +/** + * Copies one key onto a candidate as an own data property. + * + * Plain assignment to `__proto__` calls the inherited setter instead: the key + * vanishes and, if the value is an object, it becomes the candidate's prototype. + * The generators produce own `__proto__` keys on purpose, so a shrunk candidate + * built by assignment would not be a smaller version of the input at all. + */ +const copyKey = (target: Record, key: string, value: unknown): void => { + Object.defineProperty(target, key, { value, writable: true, enumerable: true, configurable: true }) +} + +/** Simpler variants of `value`, cheapest-to-check first. */ +const candidatesFor = (value: unknown): unknown[] => { + if (value === undefined || value === null) return [] + + if (typeof value === 'boolean') return value ? [false] : [] + + if (typeof value === 'number') { + // NaN would produce candidates equal to itself (Math.trunc(NaN) is NaN), and + // `!==` never rejects them, so the shrinker would burn its budget calling + // each one an improvement. + if (value === 0 || !Number.isFinite(value)) return [] + const simpler: number[] = [0] + if (!Number.isInteger(value)) simpler.push(Math.trunc(value)) + const half = Math.trunc(value / 2) + if (half !== value && half !== 0) simpler.push(half) + if (value !== 1 && value > 0) simpler.push(1) + return simpler.filter(candidate => candidate !== value) + } + + if (typeof value === 'bigint') { + if (value === 0n) return [] + return [0n, value / 2n, 1n].filter(candidate => candidate !== value) + } + + if (typeof value === 'string') { + if (value.length === 0) return [] + const simpler = ['', value.slice(0, Math.floor(value.length / 2)), value.slice(0, 1)] + // An ASCII stand-in isolates "the shape is wrong" from "the bytes are wrong". + if (/[^ -~]/u.test(value)) simpler.push('a'.repeat(Math.min(value.length, 4))) + return simpler.filter(candidate => candidate !== value) + } + + if (isTypedBytes(value)) { + if (value.length === 0) return [] + // `Buffer` is part of the call semantics for the crypto helpers — several + // take a Buffer and call Buffer methods on it — so a candidate that quietly + // became a plain Uint8Array would not be a smaller version of the input. + const keepType = (candidate: Uint8Array): Uint8Array => + Buffer.isBuffer(value) ? Buffer.from(candidate) : candidate + return [ + keepType(new Uint8Array(0)), + keepType(value.slice(0, Math.floor(value.length / 2))), + keepType(value.slice(0, 1)), + keepType(new Uint8Array(value.length)) + ] + } + + if (Array.isArray(value)) { + if (value.length === 0) return [] + const simpler: unknown[][] = [[], value.slice(0, Math.floor(value.length / 2))] + // Dropping one element at a time finds the single culprit; bounded so a + // 4000-element array cannot turn one shrink pass into a full test run. + for (let index = 0; index < Math.min(value.length, 40); index++) { + simpler.push([...value.slice(0, index), ...value.slice(index + 1)]) + } + return simpler + } + + if (isPlainObject(value)) { + const keys = Object.keys(value) + if (keys.length === 0) return [] + const simpler: Record[] = [] + if (keys.length > 2) { + const half: Record = {} + for (const key of keys.slice(0, Math.floor(keys.length / 2))) copyKey(half, key, value[key]) + simpler.push(half) + } + for (const dropped of keys) { + const without: Record = {} + for (const key of keys) if (key !== dropped) copyKey(without, key, value[key]) + simpler.push(without) + } + return simpler + } + + return [] +} + +/** Replaces the value at `path` inside `root`, structurally sharing the rest. */ +const replaceAt = (root: unknown, path: readonly (string | number)[], replacement: unknown): unknown => { + if (path.length === 0) return replacement + const [head, ...rest] = path + if (Array.isArray(root) && typeof head === 'number') { + const copy = [...root] + copy[head] = replaceAt(root[head], rest, replacement) + return copy + } + if (isPlainObject(root) && typeof head === 'string') { + return { ...root, [head]: replaceAt(root[head], rest, replacement) } + } + return root +} + +/** Every position inside `root`, breadth-first so shallow fields shrink first. */ +const positions = (root: unknown, limit: number): (string | number)[][] => { + const found: (string | number)[][] = [[]] + const queue: { value: unknown; path: (string | number)[] }[] = [{ value: root, path: [] }] + while (queue.length > 0 && found.length < limit) { + const { value, path } = queue.shift()! + if (Array.isArray(value)) { + for (let index = 0; index < value.length && found.length < limit; index++) { + const next = [...path, index] + found.push(next) + queue.push({ value: value[index], path: next }) + } + } else if (isPlainObject(value)) { + for (const key of Object.keys(value)) { + if (found.length >= limit) break + const next = [...path, key] + found.push(next) + queue.push({ value: value[key], path: next }) + } + } + } + return found +} + +const readAt = (root: unknown, path: readonly (string | number)[]): unknown => { + let cursor: unknown = root + for (const step of path) { + if (Array.isArray(cursor) && typeof step === 'number') cursor = cursor[step] + else if (isPlainObject(cursor) && typeof step === 'string') cursor = cursor[step] + else return undefined + } + return cursor +} + +/** + * Greedily minimises `value` while `stillFails` keeps holding. + * + * `stillFails` must be side-effect free and must return `false` for anything + * that fails a *different* way — otherwise shrinking walks off toward an + * unrelated bug and reports the wrong minimal case. + */ +export const shrink = async (value: T, stillFails: StillFails, options: ShrinkOptions = {}): Promise => { + const maxEvaluations = options.maxEvaluations ?? 600 + const maxPasses = options.maxPasses ?? 12 + const shrinkRoot = options.shrinkRoot ?? true + const deadline = options.deadline ?? Number.POSITIVE_INFINITY + // Checked alongside the evaluation count at every point that count is checked, + // so a single expensive candidate is the most that can run past the deadline. + const spent = () => evaluations >= maxEvaluations || performance.now() >= deadline + + let best = value + let evaluations = 0 + + for (let pass = 0; pass < maxPasses; pass++) { + let improved = false + + for (const path of positions(best, 200)) { + if (path.length === 0 && !shrinkRoot) continue + if (spent()) return best + const current = readAt(best, path) + for (const candidate of candidatesFor(current)) { + if (spent()) return best + evaluations++ + const attempt = replaceAt(best, path, candidate) as T + let holds = false + try { + holds = await stillFails(attempt) + } catch { + // A candidate that breaks the predicate itself is not a valid + // simplification; treat it as "does not reproduce". + holds = false + } + if (holds) { + best = attempt + improved = true + break + } + } + } + + if (!improved) break + } + + return best +} diff --git a/src/__fuzz__/harness/wire.ts b/src/__fuzz__/harness/wire.ts new file mode 100644 index 00000000..d420683f --- /dev/null +++ b/src/__fuzz__/harness/wire.ts @@ -0,0 +1,622 @@ +/** + * Protobuf wire-format canonicaliser. + * + * The two encoders emit fields in different orders — the Rust codec follows the + * order of the keys on the object it was handed, protobufjs follows the order the + * fields are declared in the schema. Protobuf itself says both are valid and any + * decoder must accept either, so a raw byte comparison reports a difference on + * almost every multi-field message and drowns out everything that matters. + * + * Canonicalising sorts the fields, recursively, so the comparison asks the + * question worth asking: *are the same fields carrying the same values on the + * wire*. Ordering is then reported separately, where it can be judged on its own. + * + * A length-delimited field is a nested message, a string or a byte string, and + * the wire format does not distinguish them. Given a `SchemaContext` this asks + * the schema, which is exact. Without one it falls back to parsing as a nested + * message whenever the payload frames cleanly — and that heuristic is not merely + * imprecise, it can misclassify: reordering the bytes *inside* a string that + * happens to be valid protobuf would then compare equal under canonicalisation + * and be routed to the allowlisted field-order class, excusing a changed value as + * a spelling difference. Every caller that has a schema passes it. + */ + +export interface WireField { + readonly field: number + readonly wireType: number + /** Canonical rendering of the value: hex, or the canonical form of a nested message. */ + readonly value: string + /** + * For wire type 2, the payload as hex — always, even when `value` rendered it + * as a nested message. + * + * A packed run of varints is frequently also valid as a nested message, so + * `value` may hold `{131072:0:0}` where the bytes are `0000...`. Unpacking has + * to read the bytes, not the rendering; without this the packing detector + * simply failed on those payloads and reported an ordinary two-element repeated + * field as a codec mismatch. + */ + readonly raw?: string + /** + * The field's bytes exactly as they were written, tag varint included. + * + * `value` is the *decoded* number for a varint, so `08 81 00` and `08 01` both + * render as `1:0:1` — two spellings of field 1 holding 1, one of them + * non-minimal. That is what makes the field-order class unsafe on `value` + * alone: nothing was reordered, yet the two payloads canonicalise identically + * and the difference is classified as ordering, whose intended divergence + * excuses it target-wide. The tag and length varints have the same freedom. + * + * Nested messages recurse, so a reordering *inside* a submessage still reads as + * ordering. Groups do not: their whole record is kept verbatim, which reports a + * reordered group rather than excusing it — nothing in this schema declares one. + * + * Absent on fields rebuilt by `parseNested`, which reads a rendering rather than + * bytes and so cannot know how they were written. `spell` refuses to answer for + * those instead of treating "no spelling" as a spelling they share. + */ + readonly spelled?: string + /** + * For wire type 2 that parsed as a nested message, the parsed children. + * + * Kept from the original scan rather than recovered by re-parsing `value`: + * a round trip through the rendered string loses `raw` on every child, and the + * packing checks below need those bytes. + */ + readonly nested?: readonly WireField[] +} + +interface Cursor { + readonly bytes: Uint8Array + offset: number +} + +/** The bytes of one span, exactly as written — the raw material for `spelled`. */ +const hexBetween = (bytes: Uint8Array, start: number, end: number): string => + Buffer.from(bytes.slice(start, end)).toString('hex') + +const readVarint = (cursor: Cursor): bigint | undefined => { + let result = 0n + let shift = 0n + for (let index = 0; index < 10; index++) { + if (cursor.offset >= cursor.bytes.length) return undefined + const byte = cursor.bytes[cursor.offset++]! + // Nine bytes carry 63 bits, so the tenth may only contribute bit 63 — any + // other payload bit puts the value past 64 and the encoding is malformed. + // Accepting it would let `canonicalWire` call mutated bytes well-formed, + // which routes a decoder disagreement to `proto:mutation-agreement` (a real + // codec bug) instead of `proto:mutation-interpretation` (a strictness + // difference on bytes with no defined meaning). + if (index === 9 && (byte & 0x7f) > 0x01) return undefined + result |= BigInt(byte & 0x7f) << shift + if ((byte & 0x80) === 0) return result + shift += 7n + } + return undefined +} + +const scan = (bytes: Uint8Array, depth: number, schema?: SchemaContext): WireField[] | undefined => + scanFrom({ bytes, offset: 0 }, depth, schema) + +/** + * Reads fields from `cursor` until the buffer ends, or until the group named by + * `groupField` is closed. + * + * Groups (wire types 3 and 4) are the deprecated encoding, and nothing in these + * protos declares one — but "no schema uses it" is not the same as "it is not + * protobuf". A *balanced* group is well-formed on the wire, and returning + * `undefined` for it told `canonicalWire` the bytes were unframed, which routes a + * disagreement between two decoders that both accepted the payload into + * `proto:mutation-interpretation` (a strictness difference on bytes with no + * meaning) instead of `proto:mutation-agreement` (a real codec bug). So balanced + * groups are parsed and skipped; an unmatched open or close is still malformed, + * which is the honest answer for those. + */ +const scanFrom = ( + cursor: Cursor, + depth: number, + schema?: SchemaContext, + groupField?: number +): WireField[] | undefined => { + const bytes = cursor.bytes + const fields: WireField[] = [] + + while (cursor.offset < bytes.length) { + const recordStart = cursor.offset + const tag = readVarint(cursor) + if (tag === undefined) return undefined + const tagHex = hexBetween(bytes, recordStart, cursor.offset) + + const fieldNumber = tag >> 3n + // Protobuf caps field numbers at 2^29-1. Past 2^53 `Number()` also rounds, + // so two different payloads would render as the same field — and a payload + // with an impossible field number would be called well-formed, which flips + // the robustness fuzzer's well-formed/malformed classification. + if (fieldNumber < 1n || fieldNumber > 536_870_911n) return undefined + const field = Number(fieldNumber) + const wireType = Number(tag & 7n) + + switch (wireType) { + case 0: { + const valueStart = cursor.offset + const value = readVarint(cursor) + if (value === undefined) return undefined + fields.push({ + field, + wireType, + value: value.toString(), + spelled: `${tagHex}${hexBetween(bytes, valueStart, cursor.offset)}` + }) + break + } + case 1: { + if (cursor.offset + 8 > bytes.length) return undefined + const slice = bytes.slice(cursor.offset, cursor.offset + 8) + cursor.offset += 8 + const rendered = Buffer.from(slice).toString('hex') + fields.push({ field, wireType, value: rendered, spelled: `${tagHex}${rendered}` }) + break + } + case 2: { + const lengthStart = cursor.offset + const length = readVarint(cursor) + if (length === undefined) return undefined + const lengthHex = hexBetween(bytes, lengthStart, cursor.offset) + const size = Number(length) + if (!Number.isSafeInteger(size) || size < 0 || cursor.offset + size > bytes.length) return undefined + const slice = bytes.slice(cursor.offset, cursor.offset + size) + cursor.offset += size + + // With a schema, a length-delimited field is parsed as a nested message + // only when the schema says it is one. Without that, a string or bytes + // value whose contents happen to frame as protobuf gets its apparent + // fields sorted — so reordering the *bytes of a string* would compare + // equal and be routed to the allowlisted field-order class, which is a + // changed value excused as a spelling difference. + const child = descend(schema, field) + const parseNestedHere = schema === undefined || child !== undefined + const nested = size > 0 && depth > 0 && parseNestedHere ? scan(slice, depth - 1, child) : undefined + const raw = Buffer.from(slice).toString('hex') + fields.push({ + field, + wireType, + value: nested ? `{${render(nested)}}` : raw, + raw, + nested, + // The length varint is kept as written and the payload recursed into, + // so a submessage whose fields were merely reordered still spells the + // same while a re-spelled length does not. + spelled: `${tagHex}${lengthHex}${nested ? `{${spell(nested)}}` : raw}` + }) + break + } + case 5: { + if (cursor.offset + 4 > bytes.length) return undefined + const slice = bytes.slice(cursor.offset, cursor.offset + 4) + cursor.offset += 4 + const rendered = Buffer.from(slice).toString('hex') + fields.push({ field, wireType, value: rendered, spelled: `${tagHex}${rendered}` }) + break + } + case 3: { + // Start of a group: its fields are read from the same cursor until the + // matching close tag. + if (depth <= 0) return undefined + const nested = scanFrom(cursor, depth - 1, descend(schema, field), field) + if (nested === undefined) return undefined + fields.push({ + field, + wireType, + value: `{${render(nested)}}`, + nested, + // Verbatim, close tag included: the recursive call has already moved + // the cursor past it. Reordering inside a group is therefore not + // excused as ordering — the safe direction for an encoding no message + // in this schema declares. + spelled: hexBetween(bytes, recordStart, cursor.offset) + }) + break + } + case 4: + // End of a group. Legal only as the close of the one being read; a + // stray close tag is malformed. + if (groupField === undefined || field !== groupField) return undefined + return fields + default: + // Wire types 6 and 7 have never been assigned a meaning. + return undefined + } + } + + // Running out of bytes ends the message at the top level, but leaves a group + // unterminated — which is exactly the malformed case this still rejects. + return groupField === undefined ? fields : undefined +} + +/** + * Renders a field list so that field *order* does not matter but repeated-field + * *occurrence* order does. + * + * Protobuf lets a sender emit fields in any order, so two encoders disagreeing + * about that is a representation difference. It does not let the occurrences of + * one repeated field be reordered: that order is the decoded array's order. + * Sorting every entry conflated the two — `[a, b]` and `[b, a]` canonicalised + * identically, so an encoder that reversed an array was classified as + * `proto:field-order` and excused as harmless. + * + * Sorting on the field number *alone* fixes both halves: `Array.prototype.sort` + * is stable, so occurrences of one field keep their relative order, and the + * numeric comparison also stops field 10 sorting before field 2. + * + * No wire-type tiebreaker. One repeated scalar can legally mix packed and + * unpacked occurrences, and a tiebreaker reorders those against each other: + * unpacked `1` then packed `[2]` and packed `[2]` then unpacked `1` + * canonicalised identically, though decoders read `[1, 2]` and `[2, 1]`. + */ +const render = (fields: readonly WireField[]): string => + [...fields] + .toSorted((left, right) => left.field - right.field) + .map(entry => `${entry.field}:${entry.wireType}:${entry.value}`) + .join(',') + +/** + * `render`'s exact twin: same ordering rule, but each field written out as the + * bytes that actually carried it rather than as its decoded value. + */ +const spell = (fields: readonly WireField[]): string | undefined => { + const ordered = [...fields].toSorted((left, right) => left.field - right.field) + return ordered.some(entry => entry.spelled === undefined) ? undefined : ordered.map(entry => entry.spelled).join(',') +} + +/** Order-insensitive rendering of a message's fields, or undefined if it does not parse. */ +export const canonicalWire = (bytes: Uint8Array, schema?: SchemaContext): string | undefined => { + const fields = scan(bytes, 12, schema) + return fields === undefined ? undefined : render(fields) +} + +/** Order-sensitive rendering, for telling a pure ordering difference from a real one. */ +export const orderedWire = (bytes: Uint8Array, schema?: SchemaContext): string | undefined => { + const fields = scan(bytes, 12, schema) + return fields === undefined + ? undefined + : fields.map(entry => `${entry.field}:${entry.wireType}:${entry.value}`).join(',') +} + +/** True when two payloads carry the same fields and values, whatever the order. */ +export const sameWireContent = (left: Uint8Array, right: Uint8Array, schema?: SchemaContext): boolean => { + const a = canonicalWire(left, schema) + const b = canonicalWire(right, schema) + if (a === undefined || b === undefined) return Buffer.from(left).equals(Buffer.from(right)) + return a === b +} + +/** + * True when two payloads are the same field records in a different order — every + * field written with the same bytes, only their positions moved. + * + * Strictly stronger than `sameWireContent`, and the one the field-order class has + * to ask. `sameWireContent` compares decoded values, so it also answers true when + * an encoder re-spelled a varint: field 1's value 1 as `08 81 00` rather than + * `08 01` reorders nothing, yet canonicalises identically and would be waved + * through by the ordering entry's intended divergence. A codec that started + * emitting non-minimal tag, length or value varints could then keep the run green. + */ +export const sameWireOrdering = (left: Uint8Array, right: Uint8Array, schema?: SchemaContext): boolean => { + const a = scan(left, 12, schema) + const b = scan(right, 12, schema) + if (a === undefined || b === undefined) return Buffer.from(left).equals(Buffer.from(right)) + const spelledA = spell(a) + const spelledB = spell(b) + // Byte equality, not `undefined === undefined`: an unanswerable question must + // not read as "yes, only the order moved". + if (spelledA === undefined || spelledB === undefined) return Buffer.from(left).equals(Buffer.from(right)) + return spelledA === spelledB +} + +/** The varints packed inside a length-delimited payload, or undefined if it is not one. */ +const unpackVarints = (hexPayload: string): string[] | undefined => { + const bytes = Uint8Array.from(Buffer.from(hexPayload, 'hex')) + const cursor: Cursor = { bytes, offset: 0 } + const values: string[] = [] + while (cursor.offset < bytes.length) { + const value = readVarint(cursor) + if (value === undefined) return undefined + values.push(value.toString()) + } + return values +} + +/** + * True when the only difference is packed vs unpacked repeated scalars. + * + * proto3 defaults repeated scalars to the packed encoding and every decoder must + * accept both forms, so this is a legal difference rather than data loss — but it + * is one worth naming precisely instead of excusing "the bytes differ". A field + * qualifies only when the length-delimited side unpacks to exactly the multiset + * of varints the other side wrote out one by one. + */ +/** + * Where in the schema the bytes being compared sit. + * + * Needed because a one-element packed run and a singular scalar written with the + * wrong wire type are byte-identical: `0a 01 01` is both "field 1, packed [1]" + * and "field 1, varint 1, mis-encoded as length-delimited". Treating every such + * pair as packing lets a wrong-wire-type regression be excused by the allowlisted + * packing entry; treating none of them as packing reports ordinary one-element + * repeated fields as codec bugs. Only the schema can separate the two. + * + * And it has to be the schema *at this point in the message*: protobuf field + * numbers are unique per message, not globally. This schema has 30 repeated + * scalar fields against 1734 singular ones, all drawing from the same small + * numbers, so a global "is this number ever repeated" set answers yes for + * essentially every singular field and closes nothing. + */ +export interface SchemaContext { + /** The message type being compared, as a schema path. */ + readonly path: string + /** True when this number is a repeated, packable field *of that message*. */ + readonly isRepeated: (path: string, field: number) => boolean + /** + * The message type a length-delimited field at this number carries. + * + * Returning undefined drops the context for that subtree, and a single-value + * run there is then reported rather than excused — the safe direction. + */ + readonly messageAt: (path: string, field: number) => string | undefined +} + +export const differsOnlyByPacking = (left: Uint8Array, right: Uint8Array, schema?: SchemaContext): boolean => { + const a = scan(left, 12, schema) + const b = scan(right, 12, schema) + if (!a || !b) return false + return nestedDiffersOnlyByPacking(a, b, schema) +} + +/** The context for a nested message, or undefined when the schema cannot place it. */ +const descend = (schema: SchemaContext | undefined, field: number): SchemaContext | undefined => { + if (!schema) return undefined + const path = schema.messageAt(schema.path, field) + return path === undefined ? undefined : { ...schema, path } +} + +const packableHere = (schema: SchemaContext | undefined, field: number): boolean => + schema !== undefined && schema.isRepeated(schema.path, field) + +/** + * One field number's entries in occurrence order. + * + * Order, not a sorted key. A repeated field's occurrence order is part of its + * value: `08 01 08 02` decodes to `[1, 2]` and `08 02 08 01` to `[2, 1]`. + * Sorting made those two spellings compare equal, so a reordering codec bug + * took the "identical, skip" branch and the comparison returned true — the + * proto targets then reported clean — without either side being a packed run. + */ +const spelling = (entries: readonly WireField[]): string => + entries.map(entry => `${entry.wireType}:${entry.value}`).join(',') + +const nestedDiffersOnlyByPacking = ( + a: readonly WireField[], + b: readonly WireField[], + schema?: SchemaContext +): boolean => { + const group = (fields: readonly WireField[]) => { + const byField = new Map() + for (const entry of fields) byField.set(entry.field, [...(byField.get(entry.field) ?? []), entry]) + return byField + } + + const left_ = group(a) + const right_ = group(b) + if (left_.size !== right_.size) return false + + for (const [field, leftEntries] of left_) { + const rightEntries = right_.get(field) + if (!rightEntries) return false + + if (spelling(leftEntries) === spelling(rightEntries)) continue + + // A packing difference inside a nested message is still a packing difference: + // without this, `47:2:{1:0:0}` versus `47:2:{1:2:00}` falls through as a + // generic encoder mismatch and gets reported as data loss. + if ( + leftEntries.length === 1 && + rightEntries.length === 1 && + leftEntries[0]!.wireType === 2 && + rightEntries[0]!.wireType === 2 + ) { + const leftNested = leftEntries[0]!.nested ?? parseNested(leftEntries[0]!.value) + const rightNested = rightEntries[0]!.nested ?? parseNested(rightEntries[0]!.value) + if (leftNested && rightNested && nestedDiffersOnlyByPacking(leftNested, rightNested, descend(schema, field))) + continue + } + + // One side must be a single packed run, the other a series of varints. + const packedSide = leftEntries.length === 1 && leftEntries[0]!.wireType === 2 ? leftEntries : rightEntries + const looseSide = packedSide === leftEntries ? rightEntries : leftEntries + if (packedSide.length !== 1 || packedSide[0]!.wireType !== 2) return false + if (!looseSide.every(entry => entry.wireType === 0)) return false + // Only a field the schema declares repeated *in this message* can be packed. + // The rule used to apply to a single loose value only, on the reasoning that + // two or more varints cannot be a mis-encoded singular scalar and so must be + // repeated. That is backwards: when the schema says the field is singular, + // two occurrences of it *are* the defect — a duplicate field, or a wrong + // wire type — and calling them packing routed exactly that regression into + // the target-wide packing exception. Measured on a packed `[1, 2]` against + // two varints of a field declared singular: true before, false now. + // + // With no schema the pair is ambiguous either way, so it is reported rather + // than excused, which is the same direction the single-value rule took. + if (!packableHere(schema, field)) return false + + const unpacked = unpackVarints(packedSide[0]!.raw ?? packedSide[0]!.value) + if (!unpacked) return false + if (unpacked.join(',') !== looseSide.map(entry => entry.value).join(',')) return false + } + + return true +} + +/** + * True when `left` carries a subset of `right`'s fields, with equal values. + * + * Separates "the bridge dropped a field" from "the bridge wrote a different + * value". The first is data loss with a single cause worth naming once; the + * second is a codec bug that must never be excused by the same entry. Nested + * messages recurse, so a sub-field dropped three levels down still reads as an + * omission rather than a mismatch. + */ +export const isWireSubset = (left: Uint8Array, right: Uint8Array, schema?: SchemaContext): boolean => { + const a = scan(left, 12, schema) + const b = scan(right, 12, schema) + if (!a || !b) return false + return subsetOf(a, b, schema) && render(a) !== render(b) +} + +/** + * Drops the fields the two sides spell differently only by packing. + * + * A packed repeated scalar is one length-delimited field holding N varints; the + * unpacked spelling is N separate varint fields with the same number. Matching + * them pairwise can only ever handle N = 1, so a perfectly ordinary two-element + * repeated field fell through — and a message with a packing difference in one + * field and a real omission in another was then classified as neither, and got + * reported as a value mismatch it was not. + * + * Both whole groups are consumed at once, which is the only way N > 1 works. + */ +const stripPackingDifferences = ( + left: readonly WireField[], + right: readonly WireField[], + schema?: SchemaContext +): { left: WireField[]; right: WireField[] } => { + const a = [...left] + const b = [...right] + + for (const field of new Set(a.map(entry => entry.field))) { + const mine = a.filter(entry => entry.field === field) + const theirs = b.filter(entry => entry.field === field) + if (theirs.length === 0) continue + + // Exactly one side packed, the other a run of varints. + const packedSide = + mine.length === 1 && mine[0]!.wireType === 2 + ? mine + : theirs.length === 1 && theirs[0]!.wireType === 2 + ? theirs + : undefined + if (!packedSide) continue + const looseSide = packedSide === mine ? theirs : mine + // Same rule as in `nestedDiffersOnlyByPacking`, and for the same reason: a + // field the schema does not declare repeated here cannot be packed, whether + // the loose side holds one value or five. + if (looseSide.length === 0 || !looseSide.every(entry => entry.wireType === 0)) continue + if (!packableHere(schema, field)) continue + + const unpacked = unpackVarints(packedSide[0]!.raw ?? packedSide[0]!.value) + if (!unpacked || unpacked.length !== looseSide.length) continue + if (unpacked.some((value, index) => value !== looseSide[index]!.value)) continue + + for (const entry of [...mine, ...theirs]) { + const fromA = a.indexOf(entry) + if (fromA >= 0) a.splice(fromA, 1) + const fromB = b.indexOf(entry) + if (fromB >= 0) b.splice(fromB, 1) + } + } + + return { left: a, right: b } +} + +/** Groups fields by number, keeping each number's entries in occurrence order. */ +const byFieldNumber = (fields: readonly WireField[]): Map => { + const grouped = new Map() + for (const entry of fields) { + const bucket = grouped.get(entry.field) + if (bucket) bucket.push(entry) + else grouped.set(entry.field, [entry]) + } + return grouped +} + +/** True when `entry` is carried unchanged by `candidate`, allowing a nested subset. */ +const carriedBy = (entry: WireField, candidate: WireField, schema: SchemaContext | undefined): boolean => { + if (candidate.wireType === entry.wireType && candidate.value === entry.value) return true + // Not identical: accept only when the other side is a nested message that + // contains everything this one does. + if (candidate.wireType !== 2 || entry.wireType !== 2) return false + const inner = entry.nested ?? parseNested(entry.value) + const outer = candidate.nested ?? parseNested(candidate.value) + if (inner === undefined || outer === undefined) return false + // A nested message that differs only by how its repeated scalars are packed + // has lost nothing, so it must not block the omission reading of the message + // around it. + const child = descend(schema, entry.field) + return subsetOf(inner, outer, child) || nestedDiffersOnlyByPacking(inner, outer, child) +} + +const subsetOf = (source: readonly WireField[], target: readonly WireField[], schema?: SchemaContext): boolean => { + // Packing is not data loss, so a field that differs only that way must not + // stop a message from reading as an omission. + const stripped = stripPackingDifferences(source, target, schema) + const left = byFieldNumber(stripped.left) + const right = byFieldNumber(stripped.right) + + // Per field number, and in order: each side's occurrences have to line up as a + // subsequence. Searching the whole remaining set instead — which is what a + // flat `findIndex` over every field did — made `08 01 08 02` a subset of + // `08 02 08 01` *in both directions*, so a re-ordering encoder regression was + // classified `proto:field-omission` and excused by that target-wide entry, + // even though neither side omits anything and they decode to [1, 2] and [2, 1]. + for (const [field, mine] of left) { + const theirs = right.get(field) ?? [] + let cursor = 0 + for (const entry of mine) { + // The earliest still-unclaimed occurrence at or after the cursor. Scanning + // forward only is what preserves order; consuming the match is what stops + // one occurrence upstream from covering two here. + let matched = -1 + for (let index = cursor; index < theirs.length; index++) { + if (carriedBy(entry, theirs[index]!, schema)) { + matched = index + break + } + } + if (matched < 0) return false + cursor = matched + 1 + } + } + return true +} + +/** Re-reads the rendering produced for a nested message, or undefined for opaque bytes. */ +const parseNested = (value: string): WireField[] | undefined => { + if (value === '') return [] + if (!value.startsWith('{') || !value.endsWith('}')) return undefined + const inner = value.slice(1, -1) + if (inner === '') return [] + const fields: WireField[] = [] + let depth = 0 + let start = 0 + const parts: string[] = [] + for (let index = 0; index < inner.length; index++) { + const character = inner[index] + if (character === '{') depth++ + else if (character === '}') depth-- + else if (character === ',' && depth === 0) { + parts.push(inner.slice(start, index)) + start = index + 1 + } + } + parts.push(inner.slice(start)) + for (const part of parts) { + const first = part.indexOf(':') + const second = part.indexOf(':', first + 1) + if (first < 0 || second < 0) return undefined + fields.push({ + field: Number(part.slice(0, first)), + wireType: Number(part.slice(first + 1, second)), + value: part.slice(second + 1) + }) + } + return fields +} diff --git a/src/__fuzz__/proto-codec.fuzz.test.ts b/src/__fuzz__/proto-codec.fuzz.test.ts new file mode 100644 index 00000000..bdf10f81 --- /dev/null +++ b/src/__fuzz__/proto-codec.fuzz.test.ts @@ -0,0 +1,1424 @@ +/** + * Differential fuzzing of the protobuf codec: Rust/WASM against protobufjs. + * + * This is the largest behavioural surface baileyrs replaced. Every message the + * library sends goes through `encodeProto` (`src/Socket/messages.ts:101,191,307` + * and `src/Socket/index.ts:887,975`) instead of the protobufjs runtime upstream + * uses, and the declaration audits cannot see inside it: a codec that silently + * drops a field scores 100% compatible, because only the bytes differ. + * + * Four properties, each a separate target so a failure names the layer: + * + * encode-bytes the two encoders emit byte-identical output + * decode-parity both decoders read the same bytes to the same object + * round-trip encode with one, decode with the other, get the input back + * type-coverage every message type upstream declares is known to the bridge + * + * Byte equality is checked as its own target rather than folded into the others + * because it is the strictest claim and the one most likely to have a legitimate + * exception (field ordering, packed repeated encoding). Keeping it separate means + * excusing that exception never blinds the semantic checks. + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { decodeProto, encodeProto } from '@oxidezap/whatsapp-rust-bridge' +import { equivalent, normalise, omitsKeysOnly } from './harness/compare.ts' +import { + canonicalWire, + differsOnlyByPacking, + isWireSubset, + orderedWire, + sameWireContent, + sameWireOrdering, + type SchemaContext +} from './harness/wire.ts' +import { undoRenames, type Divergence } from './harness/divergence.ts' +import { fuzz } from './harness/runner.ts' +import type { Random } from './harness/random.ts' +import { PROTO_FIELD_FLAG, PROTO_FIELD_KIND } from '../WAProto/compatibility-schema.ts' +import { + fieldsOfPath, + generateProtoCase, + generateProtoObject, + oneofGroups, + textFieldPredicate, + pickProtoPath, + messagePathOfField, + proto3OptionalFields, + PROTO_PATHS, + type ProtoCase +} from './generators/proto.ts' + +const upstream = (await import('baileys')) as unknown as { proto: Record } + +interface UpstreamType { + encode(message: unknown): { finish(): Uint8Array } + decode(bytes: Uint8Array): unknown + toObject(message: unknown, options: Record): Record +} + +/** + * Rejects a candidate the shrinker invented that is not a valid case at all. + * + * Shrinking drops object keys, so it will happily propose `{}` — and a property + * that then throws on `path.split` reports a crash in the fuzzer instead of a + * finding in the codec. Every check below is total over the candidate space + * because of this guard. + */ +const isUsableCase = (value: ProtoCase): boolean => + typeof value?.path === 'string' && + value.path.length > 0 && + typeof value.message === 'object' && + value.message !== null + +/** + * protobufjs namespaces nest, so a schema path is a lookup chain. + * + * The intermediate segments are *functions*, not objects: `proto.Message` is the + * generated Type constructor, and `Message.ExtendedTextMessage` hangs off it as a + * static. A `typeof === 'object'` guard here silently skips every nested type, + * which is most of the schema — `resolves nested message types` pins that. + */ +const upstreamType = (path: string): UpstreamType | undefined => { + let cursor: unknown = upstream.proto + for (const segment of path.split('.')) { + if (cursor === null || (typeof cursor !== 'object' && typeof cursor !== 'function')) return undefined + cursor = (cursor as Record)[segment] + } + const candidate = cursor as unknown as UpstreamType | undefined + return typeof cursor === 'function' && typeof candidate?.encode === 'function' ? candidate : undefined +} + +/** + * The one shape both decoders can be compared in. + * + * `defaults: false` matters: with defaults on, protobufjs invents `0`/`''` for + * every unset field and the comparison stops being able to see a dropped one. + */ +const TO_OBJECT = { longs: String, enums: Number, defaults: false, arrays: false, objects: false, oneofs: false } + +/** One entry in the two finite field sweeps: name and number, per declared field. */ +interface FieldCase { + readonly path: string + readonly field: string + readonly kind: number + readonly repeated: boolean +} + +type Outcome = { ok: true; value: unknown } | { ok: false; error: string } + +const attempt = (call: () => unknown): Outcome => { + try { + return { ok: true, value: call() } + } catch (error) { + return { ok: false, error: `${(error as Error)?.name ?? 'Error'}: ${(error as Error)?.message ?? String(error)}` } + } +} + +const hex = (bytes: unknown): string => + bytes instanceof Uint8Array ? Buffer.from(bytes).toString('hex') : String(bytes) + +const describeOutcome = (outcome: Outcome): unknown => (outcome.ok ? outcome.value : ``) + +/** A total stringifier for the tag helpers, mirroring the registry's own. */ +const text = (value: unknown): string => { + try { + return ( + JSON.stringify(value, (_key, nested: unknown) => (typeof nested === 'bigint' ? nested.toString() : nested)) ?? '' + ) + } catch { + return '' + } +} + +/** + * The `path#number` of every top-level field upstream wrote and the bridge did + * not, as a detail tag. + * + * `proto:field-omission` was target-wide: the structural classifier proved the + * bridge's bytes were upstream's *minus whole fields*, which rules out a changed + * value but not a newly dropped one — so a future regression that dropped a + * field nothing else covers would have counted as another hit of the existing + * entry and kept the nightly green. + * + * An earlier attempt to pin this by *message* path was reverted on measurement: + * one smoke seed named 17 paths, four named 29, and the set kept growing. + * Naming the omitted *field* instead is a different question with a different + * answer — measured at 12 distinct `path#number` pairs, identical across nine + * seeds and 21,000 generated cases. + * + * Only top-level fields: a nested omission renders as a differing length- + * delimited value here, and the entry's structural bound still covers those. + */ +const topLevelFieldNumbers = (bytes: Uint8Array): Set => { + const numbers = new Set() + let cursor = 0 + while (cursor < bytes.length) { + let shift = 0 + let tag = 0 + while (cursor < bytes.length) { + const byte = bytes[cursor++]! + tag |= (byte & 0x7f) << shift + if ((byte & 0x80) === 0) break + shift += 7 + } + const wireType = tag & 7 + const number = tag >>> 3 + if (number === 0) break + numbers.add(number) + if (wireType === 0) { + while (cursor < bytes.length && (bytes[cursor++]! & 0x80) !== 0) { + // Skipping a varint's continuation bytes. + } + } else if (wireType === 1) cursor += 8 + else if (wireType === 5) cursor += 4 + else if (wireType === 2) { + let length = 0 + let lengthShift = 0 + while (cursor < bytes.length) { + const byte = bytes[cursor++]! + length |= (byte & 0x7f) << lengthShift + if ((byte & 0x80) === 0) break + lengthShift += 7 + } + cursor += length + } else break + } + return numbers +} + +const omissionTag = (path: string, localBytes: Uint8Array, remoteBytes: Uint8Array): string | undefined => { + const mine = topLevelFieldNumbers(localBytes) + const missing = [...topLevelFieldNumbers(remoteBytes)].filter(number => !mine.has(number)).toSorted() + return missing.length === 0 ? undefined : `omits ${missing.map(number => `${path}#${number}`).join(',')}` +} + +/** Both message-level classifications, joined, so one detail can carry either. */ +const combinedTag = ( + path: string, + message: unknown, + type: UpstreamType, + bytes?: { local: Uint8Array; remote: Uint8Array } +): string | undefined => { + const tags = [ + emptyStringCoercionTag(path, message), + renumberingTag(path, message, type), + bytes === undefined ? undefined : omissionTag(path, bytes.local, bytes.remote) + ].filter((tag): tag is string => tag !== undefined) + return tags.length === 0 ? undefined : tags.join('; ') +} + +/** Appends a classification the allowlist registry cannot compute for itself. */ +const withTag = (detail: string, tag: string | undefined): string => (tag === undefined ? detail : `${detail} [${tag}]`) + +/** + * The field kinds protobuf actually packs, and that unpack as varints. + * + * "Repeated" is not the same as "packable": a repeated string or bytes field is + * always one length-delimited entry per element and is never packed, so a + * wire-type change on one is a codec regression rather than a spelling + * difference. Floats are packable but fixed-width, so they never appear as the + * varint run this comparison looks for. + */ +/** The kinds protobufjs routes through `Long.fromString`, which rejects `''`. */ +const SIXTY_FOUR_BIT_KINDS: ReadonlySet = new Set([PROTO_FIELD_KIND.signed64, PROTO_FIELD_KIND.unsigned64]) + +const PACKABLE_KINDS: ReadonlySet = new Set([ + PROTO_FIELD_KIND.enum, + PROTO_FIELD_KIND.bool, + PROTO_FIELD_KIND.signed32, + PROTO_FIELD_KIND.unsigned32, + PROTO_FIELD_KIND.signed64, + PROTO_FIELD_KIND.unsigned64 +]) + +/** + * Per-message field-number metadata, for telling packing apart from a wrong wire + * type. + * + * `differsOnlyByPacking` cannot distinguish a one-element packed run from a + * singular scalar written length-delimited — the bytes are identical — so it asks + * the schema. Field numbers are unique per message, not globally, and this schema + * has 30 repeated scalar fields against 1734 singular ones drawing from the same + * small numbers: a global set would answer "repeated" for nearly every singular + * field and excuse exactly the regression this exists to catch. + * + * The compact schema records the repeated flag and the nested type but not the + * number, so each number is recovered the way the field-number sweep recovers it + * — encode the field alone, read the tag back. Built per message, lazily, so a + * run only pays for the types it actually compares. + */ +interface FieldFacts { + readonly repeated: ReadonlySet + readonly messages: ReadonlyMap +} + +const fieldFactsByPath = new Map() + +const factsFor = (path: string): FieldFacts => { + const cached = fieldFactsByPath.get(path) + if (cached) return cached + + const repeated = new Set() + const messages = new Map() + const type = upstreamType(path) + if (type) { + for (const field of fieldsOfPath(path)) { + if ((field[3] & PROTO_FIELD_FLAG.map) !== 0) continue + const isMessage = field[1] === PROTO_FIELD_KIND.message + const one = isMessage ? {} : sampleFor(field[1]) + const value = (field[3] & PROTO_FIELD_FLAG.repeated) !== 0 ? [one] : one + const encoded = attempt(() => type.encode({ [field[0]]: value }).finish()) + if (!encoded.ok) continue + const number = firstFieldNumber(encoded.value as Uint8Array) + if (number === undefined) continue + if ((field[3] & PROTO_FIELD_FLAG.repeated) !== 0 && PACKABLE_KINDS.has(field[1])) repeated.add(number) + const nested = messagePathOfField(field) + if (nested !== undefined) messages.set(number, nested) + } + } + + const facts: FieldFacts = { repeated, messages } + fieldFactsByPath.set(path, facts) + return facts +} + +const schemaAt = (path: string): SchemaContext => ({ + path, + isRepeated: (at, field) => factsFor(at).repeated.has(field), + messageAt: (at, field) => factsFor(at).messages.get(field) +}) + +/** + * Message types upstream declares that the bridge codec has never heard of. + * + * Computed once by probing, not hard-coded: when the bridge gains a type this set + * shrinks by itself, the allowlist entry that depended on it stops matching, and + * the stale-entry check surfaces it for deletion. A hard-coded list would instead + * keep excusing a gap that had already been closed. + */ +const BRIDGE_UNKNOWN_TYPES: ReadonlySet = new Set( + PROTO_PATHS.filter(path => { + if (upstreamType(path) === undefined) return false + const outcome = attempt(() => encodeProto(path, {})) + // Only the bridge's own "I have never heard of this type" error counts. An + // encode that fails for any other reason is a different defect, and folding + // it in here would excuse it under the unknown-type entry. + return !outcome.ok && /unknown proto type/iu.test(outcome.error) + }) +) + +/** + * Whether the *populated* fields of this message reach a type the bridge cannot + * encode. + * + * Reachability from the schema alone is far too broad: `Message` can reach + * `BotAvatarMetadata`, so classifying by schema would retarget every encoder or + * decoder difference on the most important message type in the protocol to the + * known-gap entry and excuse it. Only a message that actually carries the + * unsupported field is explained by the unsupported field. + */ +const populatedTouchesUnknownType = (path: string, message: unknown, depth = 0): boolean => { + if (depth > 12 || typeof message !== 'object' || message === null) return false + if (BRIDGE_UNKNOWN_TYPES.has(path)) return true + + const fields = new Map(fieldsOfPath(path).map(field => [field[0], field] as const)) + for (const [name, value] of Object.entries(message as Record)) { + if (value === undefined || value === null) continue + const field = fields.get(name) + if (!field) continue + const nestedPath = messagePathOfField(field) + if (nestedPath === undefined) continue + if (BRIDGE_UNKNOWN_TYPES.has(nestedPath)) return true + const items = Array.isArray(value) ? value : [value] + for (const item of items) { + if (populatedTouchesUnknownType(nestedPath, item, depth + 1)) return true + } + } + return false +} + +/** + * Routes a difference to the target that names its cause. + * + * A message whose schema reaches a type the bridge does not implement will + * differ for that reason and no other, and reporting it as a generic encoder + * mismatch would bury the actual defect (the missing type) under its symptom. + */ +const encodeTarget = (path: string, message: unknown, fallback: string): string => + populatedTouchesUnknownType(path, message) ? 'proto:unknown-type-dropped' : fallback + +/** + * As above, but for a difference where both sides produced bytes: when the + * bridge's output is upstream's minus some fields, the defect is omission, and + * saying so is more useful than "the bytes differ". + */ +/** + * Whether removing `pollResultSnapshotMessageV3` makes the two encoders agree. + * + * The renumbering entry could only match this field by *name* on the byte-level + * views, because the rendering cannot express it: the canonicaliser descends + * into a nested message by field number, so upstream's 114 parses to a nested + * form where the bridge's 115 stays raw hex — the same bytes spelled two ways, + * as a direct consequence of the renumbering being excused. A comment here said + * pinning it needed the target's own tooling; this is that. + * + * Deleting the field and re-encoding answers the claim directly: if the + * renumbering is the whole difference, the two encodings agree without it. A + * corrupted value or a second changed field survives the deletion and does not. + */ +const RENUMBERED_FIELD = 'pollResultSnapshotMessageV3' + +/** + * Both sides re-encoded with the renumbered field deleted, or undefined when + * that question cannot be asked. + * + * Undefined has two causes and they are not the same: the message never carried + * the field, or one of the encoders rejected the stripped message. Callers treat + * both as "no answer", which is the safe direction — nothing gets excused on the + * strength of a comparison that did not happen. + * + * Only plain objects are rebuilt. `Object.entries` on a `Uint8Array` yields its + * indices, so rebuilding one put `{ '0': 12, '1': 7 }` where a bytes field + * belonged and a `Long` lost the prototype its encoder reads. The stripped + * message then failed to encode and the case was written off as "not isolated". + * Measured on the fixed seed before this guard: *every* "not isolated" verdict + * came from the re-encode throwing — `invalid uint32: undefined` on the bridge + * side, `empty string` upstream — and not one from a difference that actually + * survived the deletion. The renumbering entry was excusing almost nothing it + * was written to excuse. + */ +const withoutRenumberedField = ( + path: string, + message: unknown, + type: UpstreamType +): { readonly left: Uint8Array; readonly right: Uint8Array } | undefined => { + if (!text(message).includes(RENUMBERED_FIELD)) return undefined + const isPlainObject = (value: object): boolean => { + const prototype = Object.getPrototypeOf(value) as unknown + return prototype === Object.prototype || prototype === null + } + const without = (value: unknown, depth = 0): unknown => { + if (depth > 12 || typeof value !== 'object' || value === null) return value + if (Array.isArray(value)) return value.map(item => without(item, depth + 1)) + if (!isPlainObject(value)) return value + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + if (key === RENUMBERED_FIELD) continue + out[key] = without(nested, depth + 1) + } + return out + } + const stripped = without(message) + const mine = attempt(() => encodeProto(path, stripped)) + const theirs = attempt(() => type.encode(stripped).finish()) + if (!mine.ok || !theirs.ok) return undefined + return { left: mine.value as Uint8Array, right: theirs.value as Uint8Array } +} + +const renumberingTag = (path: string, message: unknown, type: UpstreamType): string | undefined => { + if (!text(message).includes(RENUMBERED_FIELD)) return undefined + const pair = withoutRenumberedField(path, message, type) + if (pair === undefined) return 'renumbering not isolated' + const { left, right } = pair + // Compared with the same tolerances the target itself applies, not by raw + // byte equality: field order and packed-vs-unpacked are legal protobuf and + // have their own targets, so a message carrying the renumbering *and* one of + // those would otherwise be tagged "not isolated" and excused by neither entry. + if (hex(left) === hex(right)) return 'renumbering only' + if (sameWireContent(left, right, schemaAt(path))) return 'renumbering only' + if (differsOnlyByPacking(left, right, schemaAt(path))) return 'renumbering only' + // Omission is a fourth such tolerance, and the one that actually co-occurs: + // measured, the residue on these is upstream writing a nested field the bridge + // does not. Named distinctly rather than folded into "renumbering only" — + // there are two documented differences here, not one, and the detail should + // say so. `byteTarget` reads the same question to route the finding to the + // omission class, which is the specific one. + if (isWireSubset(left, right, schemaAt(path))) return 'renumbering plus omission' + return 'renumbering not isolated' +} + +/** + * Whether the bridge really coerced an empty string to zero, as a detail tag. + * + * `proto-empty-string-for-numeric-field` documents exactly that coercion, but + * the registry could only check that the input held an empty string somewhere + * and that the bridge produced *some* bytes — it has no schema, so it cannot + * tell the coerced field from its neighbours, and a regression that wrote 5 or + * dropped the field stayed covered. + * + * Substitution answers it without any path resolution: if the bridge coerces + * `''` to zero, encoding the same message with every empty string replaced by + * `'0'` has to produce identical bytes. Measured on + * `Message.AudioMessage.fileLength`: `''` and `'0'` both encode to + * `0a017520002803`, and `'5'` to `0a017520052803`. + * + * Returns undefined when there is no empty string to ask about, so the tag only + * appears on the findings it is about. + */ +const emptyStringCoercionTag = (path: string, message: unknown): string | undefined => { + // Schema-aware, and it has to be. Replacing *every* empty string substitutes + // declared string fields too, which legitimately changes the bytes — measured + // on `SyncActionValue { timestamp: '', labelEditAction: { name: '' } }`, where + // substituting both gives different bytes and substituting only `timestamp` + // gives identical ones. A blanket replacement therefore reported `not coerced` + // for every message that happened to hold an empty string anywhere, which is + // how the first version of this tag broke four targets at once. + const replaceEmpty = (owner: string, value: unknown, depth = 0): unknown => { + if (depth > 12 || typeof value !== 'object' || value === null) return value + if (Array.isArray(value)) return value.map(item => replaceEmpty(owner, item, depth + 1)) + const fields = new Map(fieldsOfPath(owner).map(field => [field[0], field] as const)) + const out: Record = {} + for (const [key, nested] of Object.entries(value as Record)) { + const field = fields.get(key) + if (field !== undefined && nested === '' && SIXTY_FOUR_BIT_KINDS.has(field[1])) { + out[key] = '0' + continue + } + const child = field === undefined ? undefined : messagePathOfField(field) + out[key] = child === undefined ? nested : replaceEmpty(child, nested, depth + 1) + } + return out + } + const substituted = replaceEmpty(path, message) + if (JSON.stringify(substituted) === JSON.stringify(message)) return undefined + + const asZero = attempt(() => encodeProto(path, substituted)) + const asEmpty = attempt(() => encodeProto(path, message)) + if (!asZero.ok || !asEmpty.ok) return 'empty string not coerced' + return hex(asZero.value) === hex(asEmpty.value) ? 'empty string coerced to zero' : 'empty string not coerced' +} + +/** + * The oneof member each side's bytes resolve to, when the two disagree. + * + * protobufjs exposes a oneof's discriminator as a getter on the generated + * prototype rather than in `type.oneofs`, which is empty for every type in this + * schema. The getter returns the last member present, so which member a + * conforming consumer observes depends on the order the encoder wrote them — + * and that is exactly what the order-insensitive comparisons below discard. + * + * Returns undefined when the two agree, when the type declares no + * multi-member group, or when either side fails to decode: a decode failure is + * a different defect, reported by a different target. + */ +const oneofWinners = ( + path: string, + type: UpstreamType, + localBytes: Uint8Array, + remoteBytes: Uint8Array +): { local: string; upstream: string } | undefined => { + const groups = [...oneofGroups(path).entries()].filter(([, members]) => members.length > 1).map(([name]) => name) + if (groups.length === 0) return undefined + const prototype = (type as unknown as { prototype?: object }).prototype + if (!prototype) return undefined + const virtual = groups.filter(name => typeof Object.getOwnPropertyDescriptor(prototype, name)?.get === 'function') + if (virtual.length === 0) return undefined + + const winners = (bytes: Uint8Array): Record | undefined => { + const decoded = attempt(() => type.decode(bytes)) + if (!decoded.ok) return undefined + const out: Record = {} + for (const name of virtual) out[name] = (decoded.value as Record)[name] + return out + } + + const mine = winners(localBytes) + const theirs = winners(remoteBytes) + if (mine === undefined || theirs === undefined) return undefined + const left = JSON.stringify(mine) + const right = JSON.stringify(theirs) + return left === right ? undefined : { local: left, upstream: right } +} + +const byteTarget = ( + path: string, + message: unknown, + local: Uint8Array, + remote: Uint8Array, + fallback: string, + type?: UpstreamType +): string => { + if (populatedTouchesUnknownType(path, message)) return 'proto:unknown-type-dropped' + if (isWireSubset(local, remote, schemaAt(path))) return 'proto:field-omission' + // Asked again with the documented renumbering taken out, when there is one. + // A field the bridge writes at 115 and upstream at 114 is a field each side + // has and the other does not, which breaks the subset test outright — so a + // message carrying the renumbering *and* an omission matched neither entry + // and landed on the generic target, which is the class it is least like. + // Deleting the renumbered field and asking the same structural question is + // the same answer the renumbering entry already relies on, so nothing weaker + // is being accepted: the residue still has to be upstream's bytes minus whole + // fields, and a changed value still fails. + if (type !== undefined) { + const pair = withoutRenumberedField(path, message, type) + if (pair !== undefined && isWireSubset(pair.left, pair.right, schemaAt(path))) return 'proto:field-omission' + } + return fallback +} + +describe('protobuf codec differential — Rust/WASM vs protobufjs', () => { + it('resolves nested message types, not only top-level ones', () => { + // A guard on the fuzzers themselves. Nested types are the majority of the + // schema and all of the interesting traffic; if the lookup stops resolving + // them, every target below keeps passing while testing half of what it says. + // + // The bar is *every* path, not most of them. A percentage floor cannot see a + // small namespace drop out: stubbing `ContextInfo.*` to unresolvable still + // leaves 299/309 nested paths resolving, which passed the old 90% floor while + // ten types went unprobed. Every target below skips what it cannot resolve, so + // a silent loss reads as coverage rather than as a gap. All 498 declared paths + // resolve today, so there is nothing to carve out. + const nested = PROTO_PATHS.filter(path => path.includes('.')) + assert.ok(nested.length > 200, `expected the schema to be mostly nested types, found ${nested.length}`) + const unresolved = PROTO_PATHS.filter(path => upstreamType(path) === undefined) + assert.deepEqual( + unresolved, + [], + `${unresolved.length}/${PROTO_PATHS.length} declared types do not resolve upstream — every target skips these` + ) + assert.ok(upstreamType('Message.ExtendedTextMessage') !== undefined) + }) + + it('knows every message type upstream declares', async () => { + await fuzz({ + target: 'proto:type-coverage', + runs: PROTO_PATHS.length, + shrinkFailures: false, + exhaustive: true, + // Walk the type list rather than sampling it: coverage is a finite + // question and there is no reason to answer it probabilistically. + generate: (() => { + let cursor = 0 + return () => PROTO_PATHS[cursor++ % PROTO_PATHS.length]! + })(), + check: path => { + // An unresolved path is a finding, not a path to skip. Every other + // target in this file walks past the paths `upstreamType` cannot + // resolve, so if a namespace stopped resolving they would all keep + // passing while probing less of the schema — and this sweep, the one + // target whose whole job is coverage, would have been the loudest place + // for that to go unnoticed. + if (!upstreamType(path)) { + return { + target: 'proto:type-coverage', + input: path, + local: '', + upstream: '', + detail: 'a declared schema path does not resolve to an upstream type — every target skips it' + } + } + // Both directions. "Known" used to mean only that `encodeProto` + // accepted an empty message, so a type registered in the bridge's + // encoder but missing from its decoder passed here — and nothing else + // was guaranteed to catch it: the decode targets sample paths rather + // than sweeping them, and a type with no non-map fields never reaches + // the field-name sweep either. An empty message encodes to zero bytes, + // so the decode probe is the exact inverse of the encode one. + const encoded = attempt(() => encodeProto(path, {})) + if (!encoded.ok) { + return { + target: 'proto:type-coverage', + input: path, + local: describeOutcome(encoded), + upstream: '', + detail: 'the bridge codec cannot encode a message type upstream declares' + } + } + const decoded = attempt(() => decodeProto(path, new Uint8Array(0))) + if (decoded.ok) return [] + return { + target: 'proto:type-coverage', + input: path, + local: describeOutcome(decoded), + upstream: '', + detail: 'the bridge codec cannot decode a message type it can encode' + } + } + }) + }) + + it('names every decoded field the way upstream names it', async () => { + // A sweep rather than a sample: this is a finite question over the schema, + // and the answer for one field says nothing about the next. + // + // It matters more than it looks. The property name is the public API — a + // caller writes `chatAssignment.deviceAgentID` because that is what the + // upstream types declare. If the bridge calls it `deviceAgentId`, reads + // return undefined and, worse, writes are dropped on the way out with no + // error at all. + // Repeated fields are wrapped in a one-element array and message-valued + // fields carry `{}`; neither is excluded. A renamed repeated or message + // field is exactly as breaking as a renamed scalar, and the nested type + // having its own entry proves nothing about the *containing* field's name — + // that was the gap. Only maps stay out: the compact schema carries no key + // type, so a faithful map value cannot be built from it. + const pairs: FieldCase[] = [] + for (const path of PROTO_PATHS) { + if (!upstreamType(path)) continue + for (const field of fieldsOfPath(path)) { + if ((field[3] & PROTO_FIELD_FLAG.map) !== 0) continue + pairs.push({ path, field: field[0], kind: field[1], repeated: (field[3] & PROTO_FIELD_FLAG.repeated) !== 0 }) + } + } + + let cursor = 0 + await fuzz({ + target: 'proto:field-names', + runs: pairs.length, + shrinkFailures: false, + exhaustive: true, + generate: () => pairs[cursor++ % pairs.length]!, + check: ({ path, field, kind, repeated }) => { + const type = upstreamType(path) + if (!type) return [] + + const one = kind === PROTO_FIELD_KIND.message ? {} : sampleFor(kind) + const sample = repeated ? [one] : one + const encoded = attempt(() => type.encode({ [field]: sample }).finish()) + if (!encoded.ok) return [] + + const decoded = attempt(() => decodeProto(path, encoded.value as Uint8Array)) + // Upstream encoded a field its own schema declares and the bridge cannot + // read the result. Skipping that let the sweep claim exhaustive coverage + // of a field whose decode path it never checked. + if (!decoded.ok) { + return { + target: encodeTarget(path, { [field]: sample }, 'proto:field-names'), + input: `${path}.${field}`, + local: describeOutcome(decoded), + upstream: field, + detail: 'the bridge cannot decode a field upstream encoded from its own schema' + } + } + + const keys = Object.keys(decoded.value as Record) + // No keys at all is the field vanishing on decode, not a rename — same + // severity, and previously indistinguishable from a clean pass. + if (keys.length === 0) { + return { + target: encodeTarget(path, { [field]: sample }, 'proto:field-names'), + input: `${path}.${field}`, + local: '', + upstream: field, + detail: 'the bridge decodes nothing at all from a field upstream encoded' + } + } + // Exactly the requested field, not merely including it. These are + // singletons — one declared field, one sample value — so a second key is + // a field the decoder materialised from nothing. The encoder-side sweep + // next door cannot see it (it compares bytes, not decoded objects) and + // decode-parity samples paths rather than sweeping them, so this is the + // only place guaranteed to visit every field's decode result. + if (keys.length === 1 && keys[0] === field) return [] + if (keys.includes(field)) { + return { + target: encodeTarget(path, { [field]: sample }, 'proto:field-names'), + input: `${path}.${field}`, + local: keys.join(', '), + upstream: field, + detail: 'the bridge decodes the requested field plus keys upstream never encoded' + } + } + + // The bridge round-trips the field under another name. Confirm the + // consequence rather than just the symptom: the upstream spelling is + // dropped on encode. + const upstreamSpelling = attempt(() => encodeProto(path, { [field]: sample })) + const dropped = upstreamSpelling.ok && (upstreamSpelling.value as Uint8Array).length === 0 + + return { + target: 'proto:field-names', + input: `${path}.${field}`, + local: keys.join(', '), + upstream: field, + detail: dropped + ? 'the bridge renames the field, and silently drops the upstream spelling on encode' + : 'the bridge decodes the field under a different name' + } + } + }) + }) + + it('writes every field at the number upstream writes it at', async () => { + // The sibling of the field-name sweep, and a finite question for the same + // reason. Field *numbers* are the entire contract between two protobuf + // implementations: a field written at the wrong number is not a rename the + // peer can recover from, it is a different field. Nothing in the shape-level + // audits can see it, and byte comparison alone reports it mixed in with + // ordering and packing noise. + const pairs: FieldCase[] = [] + for (const path of PROTO_PATHS) { + if (!upstreamType(path)) continue + for (const field of fieldsOfPath(path)) { + if ((field[3] & PROTO_FIELD_FLAG.map) !== 0) continue + pairs.push({ path, field: field[0], kind: field[1], repeated: (field[3] & PROTO_FIELD_FLAG.repeated) !== 0 }) + } + } + + let cursor = 0 + await fuzz({ + target: 'proto:field-numbers', + runs: pairs.length, + shrinkFailures: false, + exhaustive: true, + generate: () => pairs[cursor++ % pairs.length]!, + check: ({ path, field, kind, repeated }) => { + const type = upstreamType(path) + if (!type) return [] + + const one = kind === PROTO_FIELD_KIND.message ? {} : sampleFor(kind) + const sample = repeated ? [one] : one + const local = attempt(() => encodeProto(path, { [field]: sample })) + const remote = attempt(() => type.encode({ [field]: sample }).finish()) + // One side rejecting a field the schema declares is a finding of its + // own, not a reason to drop the case. + if (remote.ok !== local.ok) { + return { + target: encodeTarget(path, { [field]: sample }, 'proto:field-numbers'), + input: `${path}.${field}`, + local: describeOutcome(local), + upstream: describeOutcome(remote), + detail: 'one encoder accepted a schema-declared field and the other rejected it' + } + } + if (!local.ok || !remote.ok) return [] + + const localBytes = local.value as Uint8Array + const remoteBytes = remote.value as Uint8Array + + // Upstream encoding a field its own schema declares while the bridge + // rejects it or writes nothing is how a missing or unsupported field + // shows up. Skipping it let this sweep report exhaustive coverage of a + // field it never actually checked. + if (remoteBytes.length > 0 && localBytes.length === 0) { + return { + target: encodeTarget(path, { [field]: sample }, 'proto:field-numbers'), + input: `${path}.${field}`, + local: '', + upstream: `field ${firstFieldNumber(remoteBytes) ?? '?'}`, + detail: 'upstream encodes this field and the bridge writes nothing for it' + } + } + if (localBytes.length === 0 || remoteBytes.length === 0) return [] + + const localNumber = firstFieldNumber(localBytes) + const remoteNumber = firstFieldNumber(remoteBytes) + // Nonempty bytes whose first tag will not parse — a malformed varint, or + // an illegal field number like zero — are a broken encoder, not a case to + // skip. Skipping it meant the only sweep guaranteed to visit every field + // reported a pass for the one field it could not read, and the randomised + // byte differential may never draw that field in a smoke run. + if (localNumber === undefined || remoteNumber === undefined) { + return { + target: 'proto:field-numbers', + input: `${path}.${field}`, + local: + localNumber === undefined + ? `` + : `field ${localNumber}`, + upstream: + remoteNumber === undefined + ? `` + : `field ${remoteNumber}`, + detail: 'an encoder produced bytes whose first tag does not parse' + } + } + if (localNumber !== remoteNumber) { + return { + target: 'proto:field-numbers', + input: `${path}.${field}`, + local: `field ${localNumber}`, + upstream: `field ${remoteNumber}`, + detail: 'the bridge writes this field at a different number than upstream' + } + } + + // The whole encoding, not just the first tag. These are singletons — one + // declared field, one sample value — so anything beyond that field is + // output nobody asked for: a duplicate, or a second field written + // alongside. Matching first tags said nothing about it, and neither + // sweep next door covers the gap: the field-*name* sweep passes as long + // as the requested key is among the decoded ones, and the randomised + // byte target may never draw this particular field in a smoke run. + // + // Field order and packed-vs-unpacked are legal protobuf and have their + // own targets, so they are allowed here rather than re-reported. + if (sameWireContent(localBytes, remoteBytes, schemaAt(path))) return [] + if (differsOnlyByPacking(localBytes, remoteBytes, schemaAt(path))) return [] + + return { + target: byteTarget(path, { [field]: sample }, localBytes, remoteBytes, 'proto:field-numbers'), + input: `${path}.${field}`, + local: canonicalWire(localBytes, schemaAt(path)) ?? hex(localBytes), + upstream: canonicalWire(remoteBytes, schemaAt(path)) ?? hex(remoteBytes), + detail: 'the bridge writes this field at the right number but the encodings differ' + } + } + }) + }) + + it('encodes to identical bytes', async () => { + await fuzz({ + target: 'proto:encode-bytes', + runs: 400, + generate: random => generateProtoCase(random, { outOfRangeEnums: true }), + check: value => { + if (!isUsableCase(value)) return [] + const { path, message } = value + const type = upstreamType(path) + if (!type) return [] + + const local = attempt(() => encodeProto(path, message)) + const remote = attempt(() => type.encode(message).finish()) + + if (local.ok !== remote.ok) { + return { + target: encodeTarget(path, message, 'proto:encode-bytes'), + input: { path, message }, + local: describeOutcome(local), + upstream: describeOutcome(remote), + detail: withTag( + 'one encoder accepted the message and the other rejected it', + emptyStringCoercionTag(path, message) + ) + } + } + if (!local.ok || !remote.ok) return [] + + const localBytes = local.value as Uint8Array + const remoteBytes = remote.value as Uint8Array + if (hex(localBytes) === hex(remoteBytes)) return [] + + // Same fields, different order: valid protobuf either way, so it is its + // own target rather than a failure of this one. + // + // `sameWireOrdering`, not `sameWireContent`: the latter compares decoded + // values, so an encoder that merely re-spelled a varint — field 1's value + // 1 as `08 81 00` instead of `08 01` — reorders nothing yet canonicalises + // the same, and this branch would hand it the ordering entry's + // target-wide excuse. The stricter question is the one the class claims + // to be asking anyway: are these the same field records, moved. + if (sameWireOrdering(localBytes, remoteBytes, schemaAt(path))) { + return { + target: 'proto:field-order', + input: { path, message }, + local: orderedWire(localBytes, schemaAt(path)) ?? hex(localBytes), + upstream: orderedWire(remoteBytes, schemaAt(path)) ?? hex(remoteBytes), + detail: 'same fields and values, emitted in a different order' + } + } + + // Packed vs unpacked repeated scalars: also legal either way, also its + // own target, for the same reason. + if (differsOnlyByPacking(localBytes, remoteBytes, schemaAt(path))) { + return { + target: 'proto:field-packing', + input: { path, message }, + local: canonicalWire(localBytes, schemaAt(path)) ?? hex(localBytes), + upstream: canonicalWire(remoteBytes, schemaAt(path)) ?? hex(remoteBytes), + detail: 'repeated scalars encoded unpacked on one side and packed on the other' + } + } + + return { + target: byteTarget(path, message, localBytes, remoteBytes, 'proto:encode-bytes', type), + input: { path, message }, + local: canonicalWire(localBytes, schemaAt(path)) ?? hex(localBytes), + upstream: canonicalWire(remoteBytes, schemaAt(path)) ?? hex(remoteBytes), + detail: withTag( + 'encoders put different fields or values on the wire', + combinedTag(path, message, type, { local: localBytes, remote: remoteBytes }) + ) + } + } + }) + }) + + it('decodes the same bytes to the same object', async () => { + await fuzz({ + target: 'proto:decode-parity', + runs: 400, + generate: random => generateProtoCase(random, { outOfRangeEnums: true }), + check: value => { + if (!isUsableCase(value)) return [] + const { path, message } = value + const type = upstreamType(path) + if (!type) return [] + + // Both decoders are shown bytes produced by *each* encoder: a decoder + // bug can hide behind its own encoder's output. + const sources: [string, Outcome][] = [ + ['rust-encoded', attempt(() => encodeProto(path, message))], + ['js-encoded', attempt(() => type.encode(message).finish())] + ] + + const findings: Divergence[] = [] + for (const [origin, source] of sources) { + if (!source.ok) continue + const bytes = source.value as Uint8Array + const local = attempt(() => decodeProto(path, bytes)) + const remote = attempt(() => type.toObject(type.decode(bytes), TO_OBJECT)) + + if (local.ok !== remote.ok) { + findings.push({ + target: 'proto:decode-parity', + input: { path, origin, bytes: hex(bytes) }, + local: describeOutcome(local), + upstream: describeOutcome(remote), + detail: withTag( + 'one decoder accepted the bytes and the other rejected them', + emptyStringCoercionTag(path, message) + ) + }) + continue + } + if (!local.ok || !remote.ok) continue + // The schema, so the scalar coercion knows which decimal strings are + // actually 64-bit integers. Without it `{ text: "0" }` and + // `{ text: 0 }` compare equal, and a decoder that turned a + // numeric-looking *string* field into a number is invisible here. + // + // The gate only. The reported values stay coerced the ordinary way, + // because the allowlist reads them: the rename entry undoes + // `deviceAgentId` to `deviceAgentID` and then demands equality, and + // it can only do that if both sides were normalised alike. + if (!equivalent(local.value, remote.value, { isTextField: textFieldPredicate(path) })) { + findings.push({ + // Same classification as the encode side: a decoder that drops + // a field and a decoder that reads a different value are two + // different defects and must not share one allowlist entry. + target: populatedTouchesUnknownType(path, message) + ? 'proto:unknown-type-dropped' + : // The same predicate the gate above used. Without it this + // re-normalisation folds `text: '0'` and `text: 0` back + // together, so a text-type regression that co-occurs with an + // already-known omission is classified as the omission and + // excused — decode findings carry no `omits ...` tag, so that + // entry accepts them unconditionally. + omitsKeysOnly(local.value, remote.value, { isTextField: textFieldPredicate(path) }) + ? 'proto:field-omission' + : 'proto:decode-parity', + input: { path, origin, message, bytes: hex(bytes) }, + local: normalise(local.value), + upstream: normalise(remote.value), + detail: 'decoders read the same bytes differently' + }) + } + } + return findings + } + }) + }) + + it('round-trips a message through the other implementation', async () => { + await fuzz({ + target: 'proto:round-trip', + runs: 400, + generate: random => generateProtoCase(random, { outOfRangeEnums: true }), + check: value => { + if (!isUsableCase(value)) return [] + const { path, message } = value + const type = upstreamType(path) + if (!type) return [] + + const findings: Divergence[] = [] + + // Both directions compare the *foreign* decode against the + // same-implementation decode of the identical bytes. Checking only that + // the foreign decode does not throw would miss the failure this target + // exists for: a field written at the wrong number, or with a wire type + // the other side still parses, produces bytes that decode cleanly into a + // different message. + // The known renames are undone before the shape test. A message that + // carries both a renamed field and a dropped one is not a subset either + // way round while the rename is still in place, so it fell through to the + // generic target and lost the more specific, more actionable answer: + // something was omitted. + const classify = (localView: unknown, upstreamView: unknown): string => { + if (populatedTouchesUnknownType(path, message)) return 'proto:unknown-type-dropped' + const a = undoRenames(localView) + const b = undoRenames(upstreamView) + // Under `compare`'s rules, not the default ones — for the reason + // spelled out on the decode-parity classifier: the weaker + // normalisation folds a changed text field back into agreement and + // hands a co-occurring text regression the omission entry's excuse. + const shape = { isTextField: textFieldPredicate(path) } + return omitsKeysOnly(a, b, shape) || omitsKeysOnly(b, a, shape) ? 'proto:field-omission' : 'proto:round-trip' + } + + // The same schema-aware comparison decode-parity uses. Without it, a + // declared string field holding `'0'` and a decoder that returned the + // number `0` both normalise to `0n` and the round trip reads as + // agreement — the exact type regression this target should catch. + // Measured on `Message.ExtendedTextMessage.text`: `'0'` vs `0` is + // `equivalent` without the predicate and not equivalent with it. + const compare = (left: unknown, right: unknown): boolean => + equivalent(left, right, { isTextField: textFieldPredicate(path) }) + + // A decoder that throws is classified too. "The bridge cannot decode + // this" reads as a round-trip failure, but when the throw is the + // bridge's own "unknown proto type" on a type it is already known not to + // implement, the cause is the missing type and the target that names it + // is more useful than the symptom. Narrow on purpose: any other throw, + // or that message on a type the bridge does implement, stays a + // round-trip failure. + const throwTarget = (outcome: Outcome): string => + !outcome.ok && /unknown proto type/iu.test(outcome.error) && populatedTouchesUnknownType(path, message) + ? 'proto:unknown-type-dropped' + : 'proto:round-trip' + + // Rust encodes → protobufjs reads it back. + const encodedLocally = attempt(() => encodeProto(path, message)) + if (encodedLocally.ok) { + const bytes = encodedLocally.value as Uint8Array + const readBack = attempt(() => type.toObject(type.decode(bytes), TO_OBJECT)) + if (readBack.ok) { + const own = attempt(() => decodeProto(path, bytes)) + if (own.ok && !compare(own.value, readBack.value)) { + findings.push({ + target: classify(own.value, readBack.value), + input: { path, message, direction: 'rust-encode → js-decode' }, + local: normalise(own.value), + upstream: normalise(readBack.value), + detail: 'upstream read the bridge bytes as a different message' + }) + } else if (!own.ok) { + // The reference decode failing is not a reason to skip: upstream + // just read these bytes, so the bridge cannot read back what it + // itself wrote. Skipping it here would hide a self-consistency + // defect that the mirrored branch reports when the roles swap. + findings.push({ + target: 'proto:round-trip', + input: { path, message, direction: 'rust-encode → rust-decode' }, + local: describeOutcome(own), + upstream: normalise(readBack.value), + detail: 'the bridge cannot decode its own bytes, which upstream reads fine' + }) + } + } else { + findings.push({ + target: throwTarget(readBack), + input: { path, message, direction: 'rust-encode → js-decode' }, + local: hex(bytes), + upstream: describeOutcome(readBack), + detail: 'upstream cannot decode what the bridge encoded' + }) + } + } + + // protobufjs encodes → Rust reads it back. + const encodedUpstream = attempt(() => type.encode(message).finish()) + if (encodedUpstream.ok) { + const bytes = encodedUpstream.value as Uint8Array + const readBack = attempt(() => decodeProto(path, bytes)) + if (readBack.ok) { + const own = attempt(() => type.toObject(type.decode(bytes), TO_OBJECT)) + if (own.ok && !compare(readBack.value, own.value)) { + findings.push({ + target: classify(readBack.value, own.value), + input: { path, message, direction: 'js-encode → rust-decode' }, + local: normalise(readBack.value), + upstream: normalise(own.value), + detail: 'the bridge read the upstream bytes as a different message' + }) + } else if (!own.ok) { + // Mirrors the branch above: upstream cannot read back its own + // bytes, which the bridge just read. + findings.push({ + target: 'proto:round-trip', + input: { path, message, direction: 'js-encode → js-decode' }, + local: normalise(readBack.value), + upstream: describeOutcome(own), + detail: 'upstream cannot decode its own bytes, which the bridge reads fine' + }) + } + } else { + findings.push({ + target: throwTarget(readBack), + input: { path, message, direction: 'js-encode → rust-decode' }, + local: describeOutcome(readBack), + upstream: hex(bytes), + detail: 'the bridge cannot decode what upstream encoded' + }) + } + } + + // The two directions above cannot see an encoder that drops a field. + // Each of them shows both decoders the *same* bytes, so when the bridge + // encoder omits something, neither decoder sees it and the direction + // agrees; the mirrored direction agrees too, because there the field is + // present for both. The loss only becomes visible by comparing the two + // encoders' output — through one decoder, so that a decoder difference + // cannot be mistaken for an encoder one. + if (encodedLocally.ok && encodedUpstream.ok) { + const viaLocal = attempt(() => type.toObject(type.decode(encodedLocally.value as Uint8Array), TO_OBJECT)) + const viaUpstream = attempt(() => type.toObject(type.decode(encodedUpstream.value as Uint8Array), TO_OBJECT)) + if (viaLocal.ok && viaUpstream.ok && !compare(viaLocal.value, viaUpstream.value)) { + findings.push({ + target: classify(viaLocal.value, viaUpstream.value), + input: { path, message, direction: 'both encoders → js-decode' }, + local: normalise(viaLocal.value), + upstream: normalise(viaUpstream.value), + detail: 'the two encoders wrote different messages, read back by the same decoder' + }) + } + } + + return findings + } + }) + }) + + it('agrees on explicit presence for proto3 optional fields', async () => { + // The highest-value check in this file, and a finite one — so it sweeps every + // explicit-presence scalar in the schema rather than sampling. + // + // A `proto3Optional` field set to its zero value must still reach the wire: + // that is the entire meaning of explicit presence. An encoder that treats it + // as unset drops it silently, and nothing in the declaration audits can see + // that happen because the signature is identical either way. + const pairs: { path: string; field: string; kind: number }[] = [] + for (const path of PROTO_PATHS) { + if (!upstreamType(path)) continue + for (const field of proto3OptionalFields(path)) { + if (field[1] === PROTO_FIELD_KIND.message) continue // no zero value to speak of + pairs.push({ path, field: field[0], kind: field[1] }) + } + } + + let cursor = 0 + await fuzz<{ path: string; field: string; kind: number }>({ + target: 'proto:presence', + runs: pairs.length, + shrinkFailures: false, + exhaustive: true, + generate: () => pairs[cursor++ % pairs.length]!, + check: ({ path, field, kind }) => { + const type = upstreamType(path) + if (!type) return [] + + const zero = defaultFor(kind) + const remote = attempt(() => type.encode({ [field]: zero }).finish()) + const local = attempt(() => encodeProto(path, { [field]: zero })) + + // One side rejecting is a finding, not an unusable sample. These inputs + // come from the upstream schema itself — a field it declares, set to the + // zero value the type defines — so a rejection here is an acceptance + // asymmetry a caller would hit, and skipping it would let the sweep + // report full coverage of a field it never actually checked. + if (remote.ok !== local.ok) { + return { + target: encodeTarget(path, { [field]: zero }, 'proto:presence'), + input: `${path}.${field} = ${JSON.stringify(zero instanceof Uint8Array ? '' : zero)}`, + local: describeOutcome(local), + upstream: describeOutcome(remote), + detail: withTag( + 'one encoder accepted an explicit-presence field at its zero value and the other rejected it', + emptyStringCoercionTag(path, { [field]: zero }) + ) + } + } + if (!remote.ok || !local.ok) return [] + + const localBytes = local.value as Uint8Array + const remoteBytes = remote.value as Uint8Array + if (sameWireContent(localBytes, remoteBytes, schemaAt(path))) return [] + + return { + // Not routed through byteTarget: this sweep already knows precisely + // what it is testing, and "an explicit-presence field was dropped" is + // a sharper diagnosis than the generic field-omission one it would + // otherwise be folded into. + target: encodeTarget(path, { [field]: zero }, 'proto:presence'), + input: `${path}.${field} = ${JSON.stringify(zero instanceof Uint8Array ? '' : zero)}`, + local: hex(localBytes) || '', + upstream: hex(remoteBytes) || '', + detail: + localBytes.length === 0 + ? 'an explicit-presence field set to its zero value is dropped by the bridge encoder' + : 'an explicit-presence field set to its zero value encodes differently' + } + } + }) + }) + + it('agrees on which member of a oneof wins', async () => { + const paths = PROTO_PATHS.filter(path => { + for (const members of oneofGroups(path).values()) if (members.length > 1) return true + return false + }) + + await fuzz({ + target: 'proto:oneof', + runs: 300, + generate: (random: Random) => { + const path = paths.length > 0 ? random.pick(paths) : pickProtoPath(random) + // multiOneof puts several members of the same oneof on the object at + // once. Which one survives is unspecified by protobuf itself, which is + // exactly why the two implementations can disagree. + return { path, message: generateProtoObject(random, path, 2, { multiOneof: true, fieldProbability: 0.8 }) } + }, + check: value => { + if (!isUsableCase(value)) return [] + const { path, message } = value + const type = upstreamType(path) + if (!type) return [] + + const local = attempt(() => encodeProto(path, message)) + const remote = attempt(() => type.encode(message).finish()) + // One encoder accepting what the other rejects is the sharpest form of + // oneof disagreement, so it is reported rather than skipped — the + // integers target already treats the asymmetry this way. + if (local.ok !== remote.ok) { + return { + target: encodeTarget(path, message, 'proto:oneof'), + input: { path, message }, + local: describeOutcome(local), + upstream: describeOutcome(remote), + detail: withTag( + 'one encoder accepted a multi-member oneof the other rejected', + emptyStringCoercionTag(path, message) + ) + } + } + if (!local.ok || !remote.ok) return [] + + // The winner, before anything that ignores field order. + // + // This target used to skip the ordering comparison, on the reasoning + // that protobufjs declares no runtime oneof for any of these paths — + // `type.oneofs` is indeed empty for all 14. That was the wrong place to + // look: the generated static code puts a *virtual* discriminator on the + // prototype as a getter, and all 14 have one. It resolves to the last + // member on the wire, so byte order is semantic after all. Measured on + // `Message.ButtonsMessage`, whose `header` group holds several members: + // `0a 01 78 22 00` decodes to `header = videoMessage` and the same two + // fields in the other order to `header = text`. + // + // So the winners are compared first. `sameWireContent` below ignores + // field order by design — which is right for ordinary fields and wrong + // for these — and would otherwise dismiss the difference as harmless. + const winners = oneofWinners(path, type, local.value as Uint8Array, remote.value as Uint8Array) + if (winners) { + return { + target: 'proto:oneof', + input: { path, message }, + local: winners.local, + upstream: winners.upstream, + detail: 'the two encoders put a different oneof member last, so a consumer sees a different one' + } + } + + if (sameWireContent(local.value as Uint8Array, remote.value as Uint8Array, schemaAt(path))) return [] + if (differsOnlyByPacking(local.value as Uint8Array, remote.value as Uint8Array, schemaAt(path))) return [] + return { + target: byteTarget(path, message, local.value as Uint8Array, remote.value as Uint8Array, 'proto:oneof', type), + input: { path, message }, + local: canonicalWire(local.value as Uint8Array, schemaAt(path)) ?? hex(local.value), + upstream: canonicalWire(remote.value as Uint8Array, schemaAt(path)) ?? hex(remote.value), + detail: withTag( + 'a oneof with several members set resolves differently', + emptyStringCoercionTag(path, message) + ) + } + } + }) + }) + + it('agrees on 64-bit integer boundaries', async () => { + await fuzz({ + target: 'proto:integers', + runs: 400, + generate: random => + generateProtoCase(random, { extremeIntegers: true, fieldProbability: 0.7, defaultBias: 0.05, maxDepth: 2 }), + check: value => { + if (!isUsableCase(value)) return [] + const { path, message } = value + const type = upstreamType(path) + if (!type) return [] + + const local = attempt(() => encodeProto(path, message)) + const remote = attempt(() => type.encode(message).finish()) + if (local.ok !== remote.ok) { + return { + target: encodeTarget(path, message, 'proto:integers'), + input: { path, message }, + local: describeOutcome(local), + upstream: describeOutcome(remote), + detail: withTag('one encoder accepted an integer the other rejected', combinedTag(path, message, type)) + } + } + if (!local.ok || !remote.ok) return [] + if (sameWireContent(local.value as Uint8Array, remote.value as Uint8Array, schemaAt(path))) return [] + if (differsOnlyByPacking(local.value as Uint8Array, remote.value as Uint8Array, schemaAt(path))) return [] + return { + target: byteTarget( + path, + message, + local.value as Uint8Array, + remote.value as Uint8Array, + 'proto:integers', + type + ), + input: { path, message }, + local: canonicalWire(local.value as Uint8Array, schemaAt(path)) ?? hex(local.value), + upstream: canonicalWire(remote.value as Uint8Array, schemaAt(path)) ?? hex(remote.value), + detail: withTag('integer fields encode differently', combinedTag(path, message, type)) + } + } + }) + }) +}) + +/** + * The proto3 default for a field kind, used to force explicit-presence cases. + * + * Written against the schema enums rather than their current numeric values: if + * the schema generator renumbers a kind, a literal here would silently plant a + * sample of the wrong type, the encode would throw, and the sweep would pass + * while checking nothing. + */ +function defaultFor(kind: number): unknown { + switch (kind) { + case PROTO_FIELD_KIND.string: + return '' + case PROTO_FIELD_KIND.bool: + return false + case PROTO_FIELD_KIND.bytes: + return new Uint8Array(0) + default: + return 0 + } +} + +/** The field number of the first tag in a payload, or undefined if it does not parse. */ +function firstFieldNumber(bytes: Uint8Array): number | undefined { + let result = 0n + let shift = 0n + for (let index = 0; index < bytes.length && index < 10; index++) { + const byte = bytes[index]! + result |= BigInt(byte & 0x7f) << shift + if ((byte & 0x80) === 0) { + const field = result >> 3n + return field >= 1n && field <= 536_870_911n ? Number(field) : undefined + } + shift += 7n + } + return undefined +} + +/** A non-default sample for a field kind, for the field-name sweep. */ +function sampleFor(kind: number): unknown { + switch (kind) { + case PROTO_FIELD_KIND.string: + return 'x' + case PROTO_FIELD_KIND.bool: + return true + case PROTO_FIELD_KIND.bytes: + return new Uint8Array([1]) + default: + return 7 + } +} diff --git a/src/__fuzz__/proto-robustness.fuzz.test.ts b/src/__fuzz__/proto-robustness.fuzz.test.ts new file mode 100644 index 00000000..1c9492e1 --- /dev/null +++ b/src/__fuzz__/proto-robustness.fuzz.test.ts @@ -0,0 +1,431 @@ +/** + * Decoder robustness under mutation. + * + * Everything the socket receives is attacker-influenced: a peer chooses the bytes + * of every message it sends, and the transport can corrupt the rest. The codec + * fuzzer next door asks whether two implementations agree on *valid* input; this + * one asks what happens on input that is not valid, where the answers that matter + * are not about parity at all. + * + * The properties are deliberately not "both decoders reject the same bytes". + * protobufjs is famously lenient and the Rust decoder is not, so strictness + * parity would report a difference on most mutations and say nothing useful. + * What is asserted instead: + * + * safety a decode either returns or throws a normal Error — never a WASM + * abort, never a non-Error throw, never an unbounded stall + * agreement when *both* decoders accept the bytes, they must agree on what + * they mean; a peer that gets two different messages out of one + * payload is a dessynchronisation waiting to happen + * stability what the bridge accepts, it must re-encode and re-decode to the + * same thing — otherwise a malformed payload silently mutates data + * leaks decoding a few thousand malformed payloads must not grow the heap + * without bound (skipped unless run with --expose-gc) + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { decodeProto, encodeProto } from '@oxidezap/whatsapp-rust-bridge' +import { equivalent, normalise } from './harness/compare.ts' +import { canonicalWire } from './harness/wire.ts' +import { makeRandom, type Random } from './harness/random.ts' +import { fuzz } from './harness/runner.ts' +import { generateProtoObject, textFieldPredicate, HOT_PROTO_PATHS } from './generators/proto.ts' +import { mutate, MUTATORS, type MessageCycle } from './generators/mutation.ts' + +const upstream = (await import('baileys')) as unknown as { proto: Record } + +interface UpstreamType { + encode(message: unknown): { finish(): Uint8Array } + decode(bytes: Uint8Array): unknown + toObject(message: unknown, options: Record): Record +} + +const upstreamType = (path: string): UpstreamType | undefined => { + let cursor: unknown = upstream.proto + for (const segment of path.split('.')) { + if (cursor === null || (typeof cursor !== 'object' && typeof cursor !== 'function')) return undefined + cursor = (cursor as Record)[segment] + } + const candidate = cursor as unknown as UpstreamType | undefined + return typeof cursor === 'function' && typeof candidate?.encode === 'function' ? candidate : undefined +} + +const TO_OBJECT = { longs: String, enums: Number, defaults: false, arrays: false, objects: false, oneofs: false } + +type Attempt = { ok: true; value: unknown } | { ok: false; error: unknown } + +const attempt = (call: () => unknown): Attempt => { + try { + return { ok: true, value: call() } + } catch (error) { + return { ok: false, error } + } +} + +const hex = (bytes: Uint8Array): string => Buffer.from(bytes).toString('hex') + +/** + * The tag bytes of a message cycle, per schema path. + * + * `Message` reaches itself through `ephemeralMessage` (field 40, a + * `FutureProofMessage`) and that type's `message` (field 1). Wrapping along those + * two tags gives the nesting bomb a real recursive *message* descent instead of + * one message holding a deeply nested length-delimited value — the latter tests + * the length and bounds path, which is worth doing, but a decoder with unbounded + * recursion passes it at any depth. + * + * Only `Message` has a cheap two-hop cycle worth hard-coding; everything else + * falls back to the flat wrap, and the mutator says so. + */ +const MESSAGE_CYCLE: MessageCycle = [[0xc2, 0x02], [0x0a]] + +const cycleFor = (path: string): MessageCycle | undefined => (path === 'Message' ? MESSAGE_CYCLE : undefined) + +/** + * The paths robustness cares about: what a peer can actually put on the wire. + * + * Every hot path, not the subset upstream still resolves. Two of the three + * targets here — that a malformed payload is rejected rather than trapping the + * module, and that decode → encode → decode is stable — are claims about the + * bridge alone and need no upstream codec at all. Filtering the *generator* on + * upstream meant a path dropping out of protobufjs, through schema or API drift, + * silently removed it from those two as well, and a trap or an unstable round + * trip reachable only there would have kept reporting clean. + * + * Only the agreement target needs both sides, and it already skips a path it + * cannot resolve — at the point where that actually matters. + */ +const PATHS = HOT_PROTO_PATHS + +interface MutationCase { + readonly path: string + /** The mutator chain applied, in order. */ + readonly mutator: string + readonly bytes: Uint8Array +} + +const validEncoding = (random: Random, path: string): Uint8Array => { + const message = generateProtoObject(random, path, 3, { fieldProbability: 0.5 }) + const encoded = attempt(() => encodeProto(path, message)) + return encoded.ok ? (encoded.value as Uint8Array) : new Uint8Array(0) +} + +const generateCase = (random: Random): MutationCase => { + const path = PATHS.length > 0 ? random.pick(PATHS) : 'Message' + const base = validEncoding(random, path) + const other = validEncoding(random, path) + const first = random.pick(MUTATORS) + // A couple of rounds of mutation reach states one round cannot: a truncated + // message whose surviving length prefix is then made to lie, and so on. + let bytes = mutate(random, base, other, first, cycleFor(path)).bytes + const applied: string[] = [first] + if (random.bool(0.3)) { + const second = random.pick(MUTATORS) + bytes = mutate(random, bytes, other, second, cycleFor(path)).bytes + applied.push(second) + } + // The whole chain is recorded, not just the first: the later mutation usually + // dominates the resulting bytes, and triage keys on what produced them. + return { path, mutator: applied.join(' → '), bytes } +} + +const isUsable = (value: MutationCase): boolean => + typeof value?.path === 'string' && value.path.length > 0 && value.bytes instanceof Uint8Array + +/** + * The first WASM trap seen in this process, if any. + * + * A trap leaves the module unusable for the rest of the process: every later + * decode throws for the same reason. Two things follow, and both matter. + * + * Reporting each one would turn a single defect into hundreds of findings and + * bury it in its own duplicates — so only the first is reported. + * + * And every target that decodes through the bridge has to know. The other three + * treat a failed decode as an ordinary rejection (`if (!ok) return []`), which is + * right for a validation error and badly wrong for a trap: they would report + * clean having checked nothing, which is the exact failure this file exists to + * rule out. So the flag is set by whichever target hits it first, and they all + * consult it. + */ +type WasmTrap = WebAssembly.RuntimeError | WebAssembly.CompileError + +/** Both trap types, matching what `compareOutcomes` already treats as a trap. */ +const isTrap = (error: unknown): error is WasmTrap => + error instanceof WebAssembly.RuntimeError || error instanceof WebAssembly.CompileError + +let firstTrap: WasmTrap | undefined + +/** + * Every bridge decode in this file goes through here. + * + * The trap bookkeeping has to be in one place: a decode site that skipped it + * would silently swallow the one failure mode the file is about. + */ +const decodeThroughBridge = (path: string, bytes: Uint8Array): Attempt => { + const result = attempt(() => decodeProto(path, bytes)) + if (!result.ok && isTrap(result.error)) { + firstTrap ??= result.error + } + return result +} + +/** Reports the trap once, from whichever target reached it first. */ +const trapFinding = (path: string, mutator: string, bytes: Uint8Array, failure: WasmTrap) => ({ + target: 'proto:mutation-safety', + input: { path, mutator, bytes: hex(bytes) }, + local: ``, + upstream: '', + detail: 'a malformed payload trapped the WASM module instead of being rejected' +}) + +describe('protobuf decoder robustness under mutation', () => { + it('fails cleanly on malformed input', async () => { + await fuzz({ + target: 'proto:mutation-safety', + runs: 400, + shrinkFailures: false, + // A decode that takes a second on a payload of a few hundred bytes is a + // denial-of-service vector even when the answer is eventually correct. + slowMs: 1_000, + generate: generateCase, + check: value => { + if (!isUsable(value)) return [] + const { path, mutator, bytes } = value + + // Once the module has trapped, every later decode throws for that same + // reason. Reporting each would bury the finding in its own duplicates. + if (firstTrap) return [] + + const result = decodeThroughBridge(path, bytes) + if (result.ok) return [] + + // A WebAssembly trap — `unreachable`, an out-of-bounds access — is an + // `Error` subclass, so an `instanceof Error` check alone would accept + // exactly the aborts this target says must never happen. + // + // The test is the instance, never the message text. Matching on words + // like "out of bounds" would turn any validation error that happens to + // phrase itself that way into a trap report, and there is no allowlist + // entry for this target — a false positive here is a hard suite failure. + const failure = result.error + if (isTrap(failure)) { + return trapFinding(path, mutator, bytes, failure) + } + + // Otherwise a thrown Error is the contract. A thrown string or a thrown + // undefined means the failure path is not one callers can handle. + if (failure instanceof Error) return [] + return { + target: 'proto:mutation-safety', + input: { path, mutator, bytes: hex(bytes) }, + local: ``, + upstream: '', + detail: 'a malformed payload produced something a caller cannot catch as an Error' + } + } + }) + }) + + it('agrees with upstream whenever both decoders accept the bytes', async () => { + await fuzz({ + target: 'proto:mutation-agreement', + runs: 400, + shrinkFailures: false, + generate: generateCase, + check: value => { + if (!isUsable(value)) return [] + const { path, mutator, bytes } = value + const type = upstreamType(path) + if (!type) return [] + + if (firstTrap) return [] + const local = decodeThroughBridge(path, bytes) + if (!local.ok && isTrap(local.error)) { + return trapFinding(path, mutator, bytes, local.error) + } + const remote = attempt(() => type.toObject(type.decode(bytes), TO_OBJECT)) + + // Strictness differences are expected and uninteresting; only what both + // sides claim to understand is compared. A trap is not a rejection, so it + // is caught above rather than falling into this branch and reading as a + // clean run that checked nothing. + if (!local.ok || !remote.ok) return [] + // Schema-aware, as the codec differential is. `normalise` folds every + // decimal string into a bigint so a Rust u64 can be compared against a + // protobufjs Long, which also folds a *declared string* holding `'0'` + // together with the number `0` — and the generator emits `'0'` on purpose. + // Mutated input is exactly where a parser turns one into the other, and + // the valid-message differential never sees these payloads. + if (equivalent(local.value, remote.value, { isTextField: textFieldPredicate(path) })) return [] + + // Well-formed protobuf has exactly one meaning, whether or not the + // fields make sense for this message type — two decoders reading it + // differently is a bug. Bytes that do not frame as protobuf at all have + // no defined meaning, so disagreement there is a strictness difference + // and is reported separately. + const wellFormed = canonicalWire(bytes) !== undefined + return { + target: wellFormed ? 'proto:mutation-agreement' : 'proto:mutation-interpretation', + input: { path, mutator, bytes: hex(bytes) }, + local: normalise(local.value), + upstream: normalise(remote.value), + detail: wellFormed + ? 'both decoders read the same well-formed payload differently' + : 'both decoders accepted bytes that are not well-formed protobuf, and read them differently' + } + } + }) + }) + + it('re-encodes what it accepts to something it reads the same way', async () => { + await fuzz({ + target: 'proto:mutation-stability', + runs: 400, + shrinkFailures: false, + generate: generateCase, + check: value => { + if (!isUsable(value)) return [] + const { path, mutator, bytes } = value + + if (firstTrap) return [] + const first = decodeThroughBridge(path, bytes) + if (!first.ok && isTrap(first.error)) { + return trapFinding(path, mutator, bytes, first.error) + } + if (!first.ok) return [] + + const reencoded = attempt(() => encodeProto(path, first.value as Record)) + if (!reencoded.ok) { + return { + target: 'proto:mutation-stability', + input: { path, mutator, bytes: hex(bytes) }, + local: ``, + upstream: '', + detail: 'the decoder produced an object its own encoder rejects' + } + } + + const second = decodeThroughBridge(path, reencoded.value as Uint8Array) + // The same check the first decode gets. A trap is not "cannot read back + // what it just wrote" — it leaves the module unusable for every later + // decode in the process, and reporting it as an ordinary stability + // finding buries the one failure mode this file exists to catch under a + // description that does not name it. + if (!second.ok && isTrap(second.error)) { + return trapFinding(path, mutator, bytes, second.error) + } + if (!second.ok) { + return { + target: 'proto:mutation-stability', + input: { path, mutator, bytes: hex(bytes) }, + local: ``, + upstream: '', + detail: 'the codec cannot read back what it just wrote' + } + } + + // Same predicate as the agreement check above: re-encoding and re-reading + // is where a decoder's own type confusion would show, so folding the two + // types together here would hide it in the one place it is visible. + if (equivalent(first.value, second.value, { isTextField: textFieldPredicate(path) })) return [] + return { + target: 'proto:mutation-stability', + input: { path, mutator, bytes: hex(bytes) }, + local: normalise(second.value), + upstream: normalise(first.value), + detail: 'decode → encode → decode did not reach a fixed point' + } + } + }) + }) + + it('does not leak memory across thousands of malformed decodes', { skip: typeof global.gc !== 'function' }, () => { + // Only meaningful with --expose-gc, which `npm run test:e2e` already uses. + // Without it the measurement is dominated by whatever the collector has not + // got around to, so the test skips rather than reporting noise. + const collect = global.gc as () => void + const random = makeRandom('leak-probe') + + // Cases are generated up front so the measured window contains decoding and + // nothing else — otherwise the generator's own allocation is what the delta + // would be measuring. + const warmupCases = Array.from({ length: 200 }, () => generateCase(random)) + const measuredCases = Array.from({ length: 4_000 }, () => generateCase(random)) + + const run = (cases: readonly MutationCase[]) => { + for (const value of cases) { + // Rejection is the expected outcome; this measures allocation, not + // parity. It still goes through the shared helper so a trap here is + // recorded rather than swallowed as one more rejection. + decodeThroughBridge(value.path, value.bytes) + } + } + + /** + * `heapUsed` alone is the wrong instrument here. + * + * A leaked WASM linear-memory allocation or a retained bridge buffer lives + * outside the V8 managed heap, so `heapUsed` can stay flat while the memory + * this probe exists to catch grows without bound. `external` is where a + * WebAssembly.Memory backing store shows up, so the budget covers it too. + * + * `arrayBuffers` is deliberately not added: Node reports it as a *subset* of + * `external`, not alongside it. Measured directly — allocating one 64MB + * Buffer moves both by 64MB — so summing the two would score 20MB of real + * growth as 40MB and fail the 32MB budget on a run that never leaked. + */ + const footprint = () => { + const usage = process.memoryUsage() + return usage.heapUsed + usage.external + } + + run(warmupCases) + collect() + // The helper records a trap but nothing here consulted it, so a trap reached + // only by this fixed corpus would let all 4,200 calls complete and the test + // pass on memory growth alone — with the module already unusable, which also + // makes the measurement meaningless. + const assertNoTrap = (stage: string) => { + const trap = firstTrap + assert.ok(trap === undefined, `a ${stage} payload trapped the WASM module: ${trap?.message ?? ''}`) + } + assertNoTrap('warmup') + + /** + * Measured as a slope across batches, not as one before/after delta. + * + * WASM linear memory only ever grows: the first payload bigger than anything + * in the warmup raises the high-water mark once and it never comes back down. + * Against a single total that one-time step is indistinguishable from a leak, + * and it lands in `external`, which is noisy to begin with. + * + * A leak keeps allocating, so it shows up as growth in the *last* batches as + * much as the first. A high-water step shows up once and then flattens. The + * budget is therefore applied to the second half only, with the first half + * left to absorb the one-time growth. + */ + const batches = 8 + const size = Math.ceil(measuredCases.length / batches) + const marks: number[] = [] + for (let index = 0; index < measuredCases.length; index += size) { + run(measuredCases.slice(index, index + size)) + collect() + marks.push(footprint()) + } + + // The baseline is the mark that *ends* the first half, so the window measured + // is exactly the second half. Taking `marks[length / 2]` instead would start + // one batch late and measure a window smaller than the message claimed. + assertNoTrap('measured') + + const baseline = Math.ceil(marks.length / 2) - 1 + const measuredInWindow = (marks.length - 1 - baseline) * size + const tailGrowthMb = (marks.at(-1)! - marks[baseline]!) / (1024 * 1024) + assert.ok( + tailGrowthMb < 32, + `heap + external memory grew ${tailGrowthMb.toFixed(1)}MB across the last ${measuredInWindow} of ${measuredCases.length} malformed decodes — a handle or buffer is being retained. Marks (MB): ${marks.map(mark => (mark / (1024 * 1024)).toFixed(1)).join(', ')}` + ) + }) +}) diff --git a/src/__fuzz__/pure-differential.fuzz.test.ts b/src/__fuzz__/pure-differential.fuzz.test.ts new file mode 100644 index 00000000..def408e7 --- /dev/null +++ b/src/__fuzz__/pure-differential.fuzz.test.ts @@ -0,0 +1,1509 @@ +/** + * Differential fuzzing of the pure public helpers against upstream Baileys. + * + * Everything in here is a function both libraries export under the same name, + * with no I/O, no clock and no randomness of its own — so for any input the two + * must agree, and any input where they do not is either a bug or a divergence + * somebody has to justify in `harness/divergence.ts`. + * + * The generated input is grammar-driven rather than random noise: `jidDecode` + * only gets interesting at `5511999999999_1:3@hosted.lid`, and no amount of + * random bytes ever produces that. + * + * `coverage.fuzz.test.ts` keeps this table honest — a shared export that lands + * in neither the table nor the exclusion list fails the suite. + */ + +import { createCipheriv, createHash } from 'node:crypto' +import { describe, it } from 'node:test' +import type { BinaryNode } from '../Types/index.ts' +import { compareOutcomes, runOutcome, showOutcome } from './harness/compare.ts' +import type { Divergence } from './harness/divergence.ts' +import { fuzz } from './harness/runner.ts' +import type { Random } from './harness/random.ts' +import { + generateBinaryNode, + generateCallNode, + generateDictionaryNode, + generateErrorNode, + generateMediaRetryNode, + generateMessageStanza, + generateResponseNode, + generateTaggedNode, + generateRetryReceiptNode, + generateStreamErrorNode +} from './generators/binary-node.ts' +import { generateJid, generateJidPair, generateMaybeJid, JID_SERVERS } from './generators/jid.ts' +import { + generateAnyValue, + generateBytes, + generateNumber, + generateString, + HOSTILE_STRINGS +} from './generators/values.ts' +import { PURE_TARGET_NAMES } from './targets.ts' + +/** The option-name digest the poll aggregator buckets votes by. */ +const sha256 = (value: Buffer): Buffer => createHash('sha256').update(value).digest() + +/** The child tags the content accessors are asked for, shared by generator and query. */ +const CONTENT_TAGS = ['error', 'item', 'enc', 'skmsg', 'missing'] as const + +const upstream = (await import('baileys')) as unknown as Record +const local = (await import('../index.ts')) as unknown as Record + +type Args = readonly unknown[] + +/** + * Fresh copies per side, so a helper that mutates its argument cannot + * cross-contaminate the other implementation's run. + * + * `structuredClone` is not used: it turns a `Buffer` into a plain `Uint8Array`, + * and several of these helpers call `Buffer` methods on their argument — the + * clone would silently change what is being tested. + */ +const clone = (value: T): T => { + try { + if (Buffer.isBuffer(value)) return Buffer.from(value) as T + if (value instanceof Uint8Array) return value.slice() as T + if (value instanceof Date) return new Date(value.getTime()) as T + } catch { + // Generated input can carry an own `__proto__` pointing at a typed-array + // prototype: it satisfies `instanceof` without supporting the methods. + // Fall through to the structural copy, which works on anything. + } + if (value instanceof Error) { + // Copied, not shared: returning the same object would give both sides the + // same reference, so the mutation check below could never see one side + // mutate it and a mutating helper would cross-contaminate the other run. + const copy = new Error(value.message) + copy.name = value.name + for (const key of Object.getOwnPropertyNames(value)) { + if (key === 'stack' || key === 'message') continue + Object.defineProperty(copy, key, { + // Recursed, like the object branch below: copying a nested object by + // reference would give both sides the same one, so a helper mutating + // `error.data.code` would change it for both and the mutation check + // would compare two identical objects and call it agreement. + value: clone((value as unknown as Record)[key]), + enumerable: true, + writable: true, + configurable: true + }) + } + return copy as T + } + if (Array.isArray(value)) return value.map(item => clone(item)) as T + if (typeof value === 'object' && value !== null) { + const out: Record = {} + for (const [key, nested] of Object.entries(value)) { + // Plain assignment to `__proto__` would move the prototype instead of + // copying the property, quietly changing the value under test. + Object.defineProperty(out, key, { value: clone(nested), enumerable: true, writable: true, configurable: true }) + } + return out as T + } + return value +} + +/** + * A real AES-256-CBC ciphertext, so the decrypt targets reach their success path. + * + * Generating ciphertext, key and IV independently means the block and PKCS#7 + * padding checks reject essentially every input — and the comparator reads two + * throws as agreement, so plaintext recovery was never compared. + */ +const cbcTuple = (random: Random): { sealed: Buffer; key: Buffer; iv: Buffer } => { + const key = Buffer.from(random.bytes(32)) + const iv = Buffer.from(random.bytes(16)) + const cipher = createCipheriv('aes-256-cbc', key, iv) + const plaintext = Buffer.from(random.bytes(random.pick([0, 1, 15, 16, 17, 32, 100]))) + return { sealed: Buffer.concat([cipher.update(plaintext), cipher.final()]), key, iv } +} + +/** Flips one byte, so an otherwise valid ciphertext fails authentication. */ +const corrupt = (random: Random, bytes: Buffer): Buffer => { + if (bytes.length === 0) return bytes + const copy = Buffer.from(bytes) + const index = random.below(copy.length) + copy[index] = copy[index]! ^ 0xff + return copy +} + +/** Byte sizes chosen to straddle every AES/HMAC block and key boundary. */ +const cryptoBuffer = (random: Random): Buffer => + Buffer.from(random.bytes(random.pick([0, 1, 15, 16, 17, 31, 32, 33, 48, 64, 100]))) + +/** + * Weighted toward the only length the cipher accepts, with the near-misses kept. + * + * Drawn uniformly, a valid key and a valid IV coincided about once in 36 — so + * `aesDecryptCTR` produced output on 4 of 200 inputs and the other 196 compared + * two rejections, which the oracle reads as agreement. The off-by-one lengths + * are what make the reject path interesting, so they stay; they just stop being + * the whole test. + */ +const cryptoKey = (random: Random): Buffer => + Buffer.from( + random.bytes( + random.weighted([ + [6, 32], + [1, 16], + [1, 31], + [1, 33], + [1, 0], + [1, 64] + ]) + ) + ) + +const cryptoIv = (random: Random): Buffer => + Buffer.from( + random.bytes( + random.weighted([ + [6, 16], + [1, 12], + [1, 15], + [1, 17], + [1, 0], + [1, 32] + ]) + ) + ) + +const cryptoNonce = (random: Random): Buffer => + Buffer.from( + random.bytes( + random.weighted([ + [6, 12], + [1, 16], + [1, 8], + [1, 0], + [1, 13] + ]) + ) + ) + +/** WebSocket errors carry their code in the message text, so the text is the input. */ +const wsError = (random: Random): Error => { + const error = new Error( + random.pick([ + 'Unexpected server response: 401', + 'Unexpected server response: 503', + 'Unexpected server response: notanumber', + 'Opening handshake has timed out', + '', + 'socket hang up' + ]) + ) + return error +} + +/** + * A poll message plus updates, with votes that actually match its options. + * + * `getAggregateVotesInPollMessage` buckets a vote by `sha256(optionName)`. + * Drawing `selectedOptions` from random bytes gives that match no chance at all, + * so every vote landed in the "Unknown" bucket and the helper's normal behaviour + * — assigning voters to the declared options — was never compared. Most hashes + * are therefore derived from the generated option names, with unknown and + * malformed ones still drawn often enough to keep those paths covered. + */ +const pollWithVotes = (random: Random) => { + const options = Array.from({ length: random.int(0, 4) }, () => ({ optionName: generateString(random) })) + const knownHashes = options.map(option => sha256(Buffer.from(option.optionName || ''))) + + const selected = () => { + if (knownHashes.length > 0 && random.bool(0.75)) { + return Array.from({ length: random.int(1, Math.min(2, knownHashes.length)) }, () => random.pick(knownHashes)) + } + return random.bool(0.5) ? [Buffer.from(generateBytes(random))] : [] + } + + return { + message: { pollCreationMessage: { options } }, + pollUpdates: Array.from({ length: random.int(0, 4) }, () => ({ + pollUpdateMessageKey: messageKey(random), + vote: { selectedOptions: selected() }, + senderTimestampMs: generateNumber(random) + })) + } +} + +/** + * A protocol message carrying a history-sync notification, sometimes wrapped. + * + * `getHistoryMsg` normalises the content and then reads + * `protocolMessage.historySyncNotification`. The generic content generator never + * produces that field, so all 200 inputs took the missing-notification throw and + * neither the wrapper normalisation nor the returned notification was compared. + */ +const historyNotificationContent = (random: Random): Record => { + const notification = random.bool(0.85) + ? { + fileSha256: generateBytes(random), + mediaKey: generateBytes(random), + fileLength: generateNumber(random), + syncType: random.int(0, 6), + chunkOrder: random.int(0, 3), + directPath: generateString(random) + } + : random.pick([{}, undefined]) + + const inner = { protocolMessage: { type: random.int(0, 8), historySyncNotification: notification } } + // Wrapped as often as not: the normalisation step is half of what this reads. + if (random.bool(0.4)) { + return { [random.pick(['ephemeralMessage', 'viewOnceMessage', 'deviceSentMessage'])]: { message: inner } } + } + return inner +} + +/** + * A media message with a `fileSha256`, which is the only field the digest helper + * reads. + * + * The generic content generator populates `url` and `mimetype` and nothing else, + * so `mediaMessageSHA256B64` compared `undefined` against `undefined` on every + * input and could not have caught a difference in the byte conversion or the + * base64 encoding. + */ +const mediaWithDigest = (random: Random): Record => { + const kind = random.pick(['imageMessage', 'videoMessage', 'documentMessage', 'audioMessage', 'stickerMessage']) + const digest = random.pick([ + generateBytes(random), + Buffer.from(generateBytes(random)), + new Uint8Array(0), + new Uint8Array(32), + undefined + ]) + return { [kind]: { url: generateString(random), mimetype: generateString(random), fileSha256: digest } } +} + +/** + * True when a stanza carries a `` child protobufjs cannot read back. + * + * "Cannot read back", not "cannot decode": a truncated `WebMessageInfo` prefix + * is still structurally valid protobuf and decodes without complaint, so a + * structural parse cannot tell it from a whole one — measured, 27 of 42 + * malformed payloads parse cleanly. Re-encoding does tell them apart: only a + * faithful serialisation round-trips to the identical bytes. + * + * A non-bytes payload — a string, or nothing at all — counts as unreadable + * outright, which is what the generator produces for two of its five branches. + */ +const stanzaCarriesUnreadablePayload = (node: unknown): boolean => { + if (typeof node !== 'object' || node === null) return false + const content = (node as { content?: unknown }).content + if (!Array.isArray(content)) return false + const children = content.filter( + child => typeof child === 'object' && child !== null && (child as { tag?: unknown }).tag === 'message' + ) + if (children.length === 0) return false + return children.some(child => { + const payload = (child as { content?: unknown }).content + if (!(payload instanceof Uint8Array)) return true + try { + const bytes = new Uint8Array(payload) + const proto = upstream.proto as { WebMessageInfo: UpstreamCodec } + return !Buffer.from(proto.WebMessageInfo.encode(proto.WebMessageInfo.decode(bytes)).finish()).equals( + Buffer.from(bytes) + ) + } catch { + return true + } + }) +} + +interface UpstreamCodec { + encode(message: unknown): { finish(): Uint8Array } + decode(bytes: Uint8Array): unknown +} + +interface PureTarget { + /** Export name, identical in both packages. */ + readonly name: string + readonly generate: (random: Random) => Args + /** Iterations in smoke mode. */ + readonly runs?: number + /** + * A tag describing the *input*, appended to the finding's detail. + * + * For the handful of targets where "is this input well-formed" needs a + * reference the allowlist registry cannot reach. `harness/divergence.ts` + * deliberately depends on nothing but the harness, so a predicate there can + * inspect the argument tuple structurally but cannot ask protobufjs whether a + * payload is a real `WebMessageInfo` — and for `getBinaryNodeMessages` that + * is the entire difference between the documented behaviour and a regression. + * Classifying here, where both libraries are already imported, keeps the + * registry honest without dragging a decoder into it. + */ + readonly tag?: (args: Args, localResult: unknown, upstreamResult: unknown) => string | undefined +} + +/** + * A message key, including the `*Alt` fields. + * + * `getKeyAuthor` resolves `participantAlt || remoteJidAlt || participant || + * remoteJid`. Without the first two the differential only ever exercised the + * last two branches, so a codec that reordered that precedence — or dropped an + * alt field entirely — read as agreement. Empty strings are drawn on purpose: + * the operator is `||`, so `''` has to fall through to the next branch, and + * that fall-through is the part most likely to drift. + */ +const altJid = (random: Random) => + random.weighted<() => string | undefined>([ + [6, () => undefined], + [3, () => generateJid(random)], + [1, () => ''] + ])() + +/** + * Text for `extractUrlFromText`, which is one `String.match` against + * `URL_REGEX` and returns the first hit. + * + * Measured over 250 draws of the generic string generator: *zero* matched, so + * the differential only ever compared `undefined` to `undefined` — a regex that + * had been changed on one side to match nothing at all would have passed. The + * cases below are built around the parts of that pattern that can drift: the + * https-only prefix, the two-letter TLD minimum, the optional port and path, + * where the match stops (whitespace), which of several URLs comes back first, + * and the negative lookahead that rejects `user:pass@host` — the one rule in + * there that exists for a security reason rather than a parsing one. + */ +const urlText = (random: Random): string => { + const host = () => + random.pick([ + 'example.com', + 'a.co', + 'sub.domain.example.org', + 'xn--80ak6aa92e.com', + '127.0.0.1.nip.io', + 'UPPER.CASE.NET' + ]) + const port = () => (random.bool(0.25) ? `:${random.pick([80, 443, 8080, 0, 65535, 99999])}` : '') + const path = () => + random.bool(0.5) ? random.pick(['/', '/a/b?c=d&e=f', '/#frag', '/%20%zz', '/trailing.', '/a b']) : '' + const url = () => `https://${host()}${port()}${path()}` + + return random.weighted<() => string>([ + // A bare URL, and a URL embedded in surrounding words — the match has to + // start and stop in the right place. + [4, url], + [3, () => `${random.pick(['see ', 'go to ', '(', 'x'])}${url()}${random.pick([' now', ')', '', '\nnext'])}`], + // More than one: the helper returns the *first*, and which one that is has + // to be the same on both sides. + [2, () => `${url()} and ${url()}`], + // Near-misses that must not match: wrong scheme, single-letter TLD, no TLD, + // and the credential form the lookahead exists to reject. + [ + 2, + () => + random.pick([ + 'http://example.com', + 'https://example.c', + 'https://localhost', + 'https://user:pass@example.com/x', + 'https://user:pass@example.com and https://example.net', + 'httpsx://example.com', + 'https://' + ]) + ], + [1, () => generateString(random)] + ])() +} + +/** + * Input for `encodeBase64EncodedStringForUpload`, which is three replacements + * and a percent-encode: `+`→`-`, `/`→`_`, trailing `=` stripped. + * + * The generic string generator does not reach any of them in practice. Measured + * over 250 draws: 8 contained `+` or `/` and *zero* ended with `=`, so the + * padding strip — the one rule with an anchor in it, and the one most likely to + * be got wrong — was never executed on either side. Real base64 of random bytes + * produces all three naturally; the hand-written cases pin the edges the random + * draw would need luck to hit. + */ +const uploadBase64 = (random: Random): string => + random.weighted<() => string>([ + [6, () => Buffer.from(random.bytes(random.int(1, 48))).toString('base64')], + [ + 3, + () => + random.pick([ + // One, two, and no padding characters. + 'AA==', + 'AAA=', + 'AAAA', + // Padding is only stripped at the end: an interior `=` must survive. + 'AA==BB==', + // Nothing but padding, and the empty string. + '====', + '', + // Both replaced characters, adjacent, with padding behind them. + '+/+/==', + // Characters encodeURIComponent has to escape but the replacements do not touch. + 'a b&c?d#e%f' + ]) + ], + [1, () => generateString(random)] + ])() + +/** + * `content` as upstream's copy would leave it, or undefined if it will not go. + * + * `generateWAMessageFromContent`'s upstream twin rebuilds content through the + * protobuf codec, and that round trip is the entire difference between the two + * implementations' copies. Running it here answers "is this difference the + * documented one" exactly, where a structural description of its effects cannot. + */ +const attemptRoundTrip = (content: unknown): unknown => { + try { + const type = (upstream.proto as { Message: UpstreamMessageCodec }).Message + return type.toObject(type.decode(type.encode(type.fromObject(structuredClone(content))).finish()), { + longs: String, + enums: Number, + defaults: false, + arrays: false, + objects: false, + oneofs: false + }) + } catch { + return undefined + } +} + +interface UpstreamMessageCodec { + fromObject(value: unknown): unknown + encode(message: unknown): { finish(): Uint8Array } + decode(bytes: Uint8Array): unknown + toObject(message: unknown, options: Record): Record +} + +const messageKey = (random: Random) => ({ + remoteJid: generateMaybeJid(random), + fromMe: random.bool(), + id: random.pick(['ABC123', '', '3EB0' + '0'.repeat(32), 'BAE5' + 'F'.repeat(12)]), + participant: random.bool(0.3) ? generateMaybeJid(random) : undefined, + participantAlt: altJid(random), + remoteJidAlt: altJid(random) +}) + +const receipt = (random: Random) => ({ + userJid: generateJid(random), + receiptTimestamp: random.bool() ? generateNumber(random) : undefined, + readTimestamp: random.bool() ? generateNumber(random) : undefined, + playedTimestamp: random.bool() ? generateNumber(random) : undefined +}) + +/** A shallow message-content shape: enough to drive the content-type resolvers. */ +/** + * Mimetypes for the media branches. + * + * `extensionForMediaMessage` is `mimetype.split(';')[0].split('/')[1]`, so the + * parameter and the slash are the whole of its logic. Generic hostile strings + * contain neither: measured over 200 draws, not one carried a `;` and only 4 + * calls returned an extension at all — the other 196 threw on an absent or + * unusable mimetype, which the oracle reads as agreement. The malformed ones + * stay, because the split has to survive them; they just stop being all of it. + */ +const MIME_TYPES = [ + 'image/jpeg', + 'image/webp', + 'video/mp4', + 'audio/ogg; codecs=opus', + 'application/pdf', + 'image/jpeg; charset=binary', + // The shapes the two splits have to survive. + 'image/', + '/jpeg', + 'image', + ';', + '' +] as const + +const mediaBody = (random: Random): Record => ({ + url: generateString(random), + mimetype: random.bool(0.75) ? random.pick(MIME_TYPES) : generateString(random) +}) + +const messageContent = (random: Random, depth = 2): Record => { + const key = random.pick([ + 'conversation', + 'extendedTextMessage', + 'imageMessage', + 'videoMessage', + 'documentMessage', + 'audioMessage', + 'stickerMessage', + 'reactionMessage', + // `cleanMessage` normalises the key inside these two, and `isRealMessage` + // treats both as non-real. Neither was reachable: `pollUpdateMessage` was + // not in this list at all, and `reactionMessage` fell through to the media + // body below, so all 16 draws of it on the fixed seed carried `{url, + // mimetype}` and no `.key` — `normaliseKey(undefined)` then threw on both + // sides, which the oracle reads as agreement. + 'pollUpdateMessage', + // The three types `extensionForMediaMessage` special-cases to `.jpeg` + // before it ever looks at a mimetype. None was reachable, so a regression + // removing that branch passed every run. + 'locationMessage', + 'liveLocationMessage', + 'productMessage', + 'protocolMessage', + 'ephemeralMessage', + 'viewOnceMessage', + 'viewOnceMessageV2', + 'viewOnceMessageV2Extension', + 'documentWithCaptionMessage', + 'editedMessage', + 'deviceSentMessage', + 'senderKeyDistributionMessage', + 'pollCreationMessage', + 'messageContextInfo' + ]) + + const wrappers = new Set([ + 'ephemeralMessage', + 'viewOnceMessage', + 'viewOnceMessageV2', + 'viewOnceMessageV2Extension', + 'documentWithCaptionMessage', + 'editedMessage', + 'deviceSentMessage' + ]) + + if (wrappers.has(key)) { + // Wrappers nest, and the unwrapping helpers recurse — so nest them, including + // past the point where a naive implementation would blow the stack. + const inner = depth > 0 ? messageContent(random, depth - 1) : { conversation: generateString(random) } + return { [key]: random.bool(0.85) ? { message: inner } : random.pick([{}, undefined, inner]) } + } + + if (key === 'conversation') return { conversation: generateString(random) } + if (key === 'extendedTextMessage') { + return { + extendedTextMessage: { + text: generateString(random), + contextInfo: random.bool(0.5) + ? { stanzaId: generateString(random), participant: generateMaybeJid(random) } + : undefined + } + } + } + // The two with a nested key `cleanMessage` rewrites. Usually present, so the + // `fromMe`/`remoteJid`/`participant` rewrites actually run; sometimes absent, + // because both implementations reaching `normaliseKey(undefined)` is a real + // shared behaviour and dropping it would stop testing that they still agree. + if (key === 'reactionMessage') { + return { + reactionMessage: { + key: random.bool(0.85) ? messageKey(random) : undefined, + text: generateString(random) + } + } + } + if (key === 'pollUpdateMessage') { + return { + pollUpdateMessage: { + pollCreationMessageKey: random.bool(0.85) ? messageKey(random) : undefined, + senderTimestampMs: generateNumber(random) + } + } + } + // The `.jpeg` types carry no mimetype in practice, and the helper never reads + // one for them — giving them a media body would test the wrong branch. + if (key === 'locationMessage' || key === 'liveLocationMessage' || key === 'productMessage') { + return { + [key]: random.bool(0.8) + ? { degreesLatitude: generateNumber(random), degreesLongitude: generateNumber(random) } + : {} + } + } + return { [key]: random.bool(0.7) ? mediaBody(random) : {} } +} + +/** A deliberately over-nested wrapper chain, to compare recursion limits rather than shapes. */ +const deeplyNestedContent = (random: Random): Record => { + let content: Record = { conversation: 'bottom' } + // Drawn once. Re-drawing the bound every iteration compounds a survival + // probability instead of choosing a depth: measured over 500 chains, the + // median came out at 24 and nothing exceeded 67, so the hundreds-deep case + // this generator exists for was unreachable. Drawn once: median 196, 364 of + // 500 past 100. + const target = random.int(1, 400) + for (let depth = 0; depth < target; depth++) { + content = { + [random.pick(['ephemeralMessage', 'viewOnceMessage', 'documentWithCaptionMessage'])]: { message: content } + } + } + return content +} + +const TARGETS: readonly PureTarget[] = [ + // ---- src/WABinary/jid-utils.ts ----------------------------------------- + { name: 'jidDecode', generate: random => [generateMaybeJid(random)], runs: 400 }, + { + name: 'jidEncode', + generate: random => [ + random.weighted([ + [6, generateString(random)], + [2, generateNumber(random)], + [1, null], + [1, undefined] + ]), + random.pick(JID_SERVERS), + random.bool(0.5) ? generateNumber(random) : undefined, + random.bool(0.3) ? generateNumber(random) : undefined + ], + runs: 400 + }, + { name: 'jidNormalizedUser', generate: random => [generateMaybeJid(random)], runs: 400 }, + { name: 'areJidsSameUser', generate: random => generateJidPair(random), runs: 400 }, + { name: 'transferDevice', generate: random => generateJidPair(random), runs: 300 }, + { + name: 'getServerFromDomainType', + generate: random => [ + random.pick(JID_SERVERS), + random.weighted([ + [4, random.pick([0, 1, 128, 129])], + [3, generateNumber(random)], + [2, undefined], + [1, generateString(random)] + ]) + ] + }, + ...( + [ + 'isJidBot', + 'isJidBroadcast', + 'isJidGroup', + 'isJidMetaAI', + 'isJidNewsletter', + 'isJidStatusBroadcast', + 'isLidUser', + 'isPnUser', + 'isHostedLidUser', + 'isHostedPnUser' + ] as const + ).map(name => ({ name, generate: (random: Random) => [generateMaybeJid(random)], runs: 300 })), + + // ---- src/Utils/generics.ts --------------------------------------------- + { + name: 'encodeBigEndian', + generate: random => [generateNumber(random), random.bool(0.7) ? random.pick([1, 2, 3, 4, 8, 0, -1]) : undefined] + }, + { name: 'unpadRandomMax16', generate: random => [generateBytes(random)], runs: 300 }, + { + name: 'toNumber', + generate: random => [ + random.weighted([ + [3, generateNumber(random)], + [ + 3, + { + low: random.int(-2_147_483_648, 2_147_483_647), + high: random.int(-2_147_483_648, 2_147_483_647), + unsigned: random.bool() + } + ], + [2, generateString(random)], + [2, undefined], + [1, null], + [1, generateAnyValue(random)] + ]) + ] + }, + { name: 'isStringNullOrEmpty', generate: random => [generateAnyValue(random)] }, + { + name: 'getKeyAuthor', + generate: random => [random.bool(0.9) ? messageKey(random) : undefined, generateMaybeJid(random)] + }, + { + name: 'getStatusFromReceiptType', + generate: random => [ + random.weighted([ + [ + 5, + random.pick(['read', 'read-self', 'played', 'hist_sync', 'peer_msg', 'sender', 'inactive', 'delivery', '']) + ], + [2, generateString(random)], + [1, undefined] + ]) + ] + }, + { + name: 'getCallStatusFromNode', + // Call tags, not the generic pool: none of the tags this switches on appear + // there, so every input fell to the `ringing` default. + generate: random => [random.bool(0.85) ? generateCallNode(random) : generateBinaryNode(random, 1)], + runs: 250 + }, + { + name: 'getErrorCodeFromStreamError', + // A real stream error most of the time: the generic error node names its + // child `error` and puts the code on the child, so every case took the same + // bad-session default. + generate: random => [random.bool(0.85) ? generateStreamErrorNode(random) : generateErrorNode(random)], + runs: 250 + }, + { + name: 'isWABusinessPlatform', + generate: random => [ + random.weighted([ + [4, random.pick(['smba', 'smbi', 'android', 'ios', ''])], + [2, generateString(random)] + ]) + ] + }, + { name: 'bytesToCrockford', generate: random => [Buffer.from(generateBytes(random))], runs: 250 }, + { + name: 'trimUndefined', + generate: random => { + const object: Record = {} + const keyCount = random.int(0, 6) + for (let index = 0; index < keyCount; index++) { + const key = random.pick(['a', 'b', 'c', 'id', '__proto__', '']) + const value = random.bool(0.4) ? undefined : generateAnyValue(random) + Object.defineProperty(object, key, { value, enumerable: true, writable: true, configurable: true }) + } + return [object] + } + }, + { + name: 'unixTimestampSeconds', + // No `undefined`: the two implementations would each call `Date.now()`, and a + // pair of calls straddling a second boundary reports a difference that is not + // one — a flake no seed can reproduce. + generate: random => [ + random.weighted([ + [4, new Date(random.int(0, 4_102_444_800_000))], + [1, new Date(Number.NaN)], + [1, new Date(-1)] + ]) + ] + }, + { + name: 'generateParticipantHashV2', + generate: random => [Array.from({ length: random.int(0, 8) }, () => generateJid(random))], + runs: 200 + }, + { name: 'encodeNewsletterMessage', generate: random => [messageContent(random)], runs: 250 }, + + // ---- src/Utils/messages.ts --------------------------------------------- + { + name: 'getContentType', + generate: random => [random.bool(0.9) ? messageContent(random) : (random.pick([undefined, null, {}]) as unknown)], + runs: 400 + }, + { + name: 'normalizeMessageContent', + generate: random => + random.bool(0.12) ? [deeplyNestedContent(random)] : [random.bool(0.9) ? messageContent(random) : undefined], + runs: 300 + }, + { + name: 'extractMessageContent', + generate: random => + random.bool(0.12) ? [deeplyNestedContent(random)] : [random.bool(0.9) ? messageContent(random) : undefined], + runs: 300 + }, + { + name: 'getDevice', + generate: random => [ + random.weighted([ + [4, random.pick(['3EB0' + 'A'.repeat(32), 'BAE5' + 'B'.repeat(12), '3A' + 'C'.repeat(16), 'ABCD'])], + [3, generateString(random)], + [1, undefined] + ]) + ] + }, + { + name: 'aggregateMessageKeysNotFromMe', + // The helper buckets by `${remoteJid}:${participant || ''}`, so what it has + // to get right is which keys share a bucket. Independently generated keys + // never collided on a valid chat — 0 of 200 draws on the fixed seed — and + // the only repeated composite keys were the malformed `undefined:` ones. A + // regression that bucketed by `remoteJid` alone, merging two participants' + // receipts in a group, therefore passed every run. + // + // So a chat and a participant are drawn first and shared across the batch: + // same chat and same participant (one bucket), same chat and different + // participants (which must stay separate), and unrelated keys alongside. + generate: random => { + const chat = generateJid(random) + const participant = generateJid(random) + const other = generateJid(random) + return [ + Array.from({ length: random.int(0, 6) }, () => + random.weighted<() => Record>([ + [3, () => ({ ...messageKey(random), fromMe: false, remoteJid: chat, participant })], + [3, () => ({ ...messageKey(random), fromMe: false, remoteJid: chat, participant: other })], + // Same chat, no participant at all: `participant || ''` puts these in + // their own bucket, which is a third case and not the same as either. + [2, () => ({ ...messageKey(random), fromMe: false, remoteJid: chat, participant: undefined })], + // `fromMe` keys are skipped entirely, and unrelated keys keep the + // batch from being one bucket by construction. + [1, () => ({ ...messageKey(random), fromMe: true, remoteJid: chat, participant })], + [3, () => messageKey(random)] + ])() + ) + ] + }, + runs: 200 + }, + { + name: 'hasNonNullishProperty', + // The helper is `key in message && message[key] !== null && !== undefined`, + // so the whole of it lives in whether the object actually has the key. + // Drawing an arbitrary object and an independent hostile string as the key + // meant it essentially never did — 1 of 150 draws on the fixed seed — so + // every call took the false branch and an implementation that returned + // `false` unconditionally passed. The key is now drawn from the object's own + // property names most of the time, across the five cases that decide the + // answer: a real value, `null`, `undefined`, absent, and inherited. + generate: random => { + const value = random.weighted<() => unknown>([ + [4, () => generateString(random)], + [2, () => null], + [2, () => undefined], + [1, () => 0], + [1, () => false] + ])() + const own = random.pick(['text', 'caption', 'image', 'delete', '__proto__', 'constructor', '']) + const object: Record = {} + // `defineProperty`, not assignment: `object.__proto__ = x` moves the + // prototype instead of creating an own property, and `__proto__` is one of + // the keys worth asking about. + Object.defineProperty(object, own, { value, enumerable: true, writable: true, configurable: true }) + + return [ + random.bool(0.8) ? object : generateAnyValue(random), + random.weighted<() => unknown>([ + // The key the object owns: the true branch, and the two nullish + // values that must still answer false despite `in` succeeding. + [5, () => own], + // A key it does not own, but which `in` finds on the prototype — + // `toString`, `valueOf`. The helper uses `in`, so these answer true, + // and that is worth pinning rather than discovering later. + [2, () => random.pick(['toString', 'valueOf', 'hasOwnProperty', 'constructor'])], + [2, () => random.pick(HOSTILE_STRINGS)] + ])() + ] + } + }, + { + name: 'updateMessageWithReceipt', + // The helper merges in place when a receipt for the same `userJid` is already + // stored, and appends otherwise. Two independently generated receipts almost + // never share a jid, so the merge branch — the one that can duplicate a user + // or fail to overwrite their timestamps — was effectively never taken. + generate: random => { + const incoming = receipt(random) + const stored = { ...receipt(random), userJid: random.bool(0.6) ? incoming.userJid : generateJid(random) } + return [{ userReceipt: random.bool(0.75) ? [stored] : random.pick([[], undefined]) }, incoming] + } + }, + { + name: 'updateMessageWithReaction', + // The helper's whole job is "replace the previous reaction from the same + // author, then append". Drawing the stored and incoming keys independently + // left that replacement essentially unexercised: of 150 draws on the fixed + // seed, 79 carried a stored reaction and the filter removed it 18 times — + // but all 18 were the degenerate case where both keys have `fromMe: true` + // and `getKeyAuthor` returns the same `'me'` for both. Zero shared an actual + // JID author, so a regression in author resolution for anyone *else's* + // reaction, or one that left two reactions from one participant, stayed + // green. The stored key is now the incoming one most of the time, the same + // way the receipt and poll-update generators already do it. + generate: random => { + const incoming = { key: messageKey(random), text: random.bool(0.8) ? generateString(random) : undefined } + const storedKey = random.weighted<() => Record>([ + // The same author, same key: the replacement has to fire. + [5, () => ({ ...incoming.key })], + // The same author reached by a different spelling of the key, so the + // resolution order in `getKeyAuthor` is what decides it rather than a + // shallow object comparison. + [3, () => ({ ...messageKey(random), participantAlt: incoming.key.participantAlt })], + // An unrelated author: the append path, which still has to work. + [3, () => messageKey(random)] + ])() + return [ + { reactions: random.bool(0.75) ? [{ key: storedKey, text: generateString(random) }] : undefined }, + incoming + ] + } + }, + { + name: 'updateMessageWithPollUpdate', + // The helper replaces any prior update from the same author and keeps the new + // one only when it carries a non-empty vote. With no stored updates and no + // selectedOptions, every input took the empty-vote path and left the list + // empty — neither insertion nor replacement was ever compared. So the message + // starts with an update, often from the same author as the incoming one. + generate: random => { + const author = messageKey(random) + const existing = random.bool(0.7) + ? [ + { + pollUpdateMessageKey: random.bool(0.6) ? author : messageKey(random), + vote: { selectedOptions: [Buffer.from(generateBytes(random))] }, + senderTimestampMs: generateNumber(random) + } + ] + : random.pick([[], undefined]) + return [ + { pollUpdates: existing }, + { + pollUpdateMessageKey: author, + vote: random.bool(0.7) + ? { selectedOptions: Array.from({ length: random.int(1, 2) }, () => Buffer.from(generateBytes(random))) } + : random.pick([{ selectedOptions: [] }, {}, undefined]), + senderTimestampMs: generateNumber(random) + } + ] + } + }, + { + name: 'prepareDisappearingMessageSettingContent', + generate: random => [random.bool(0.8) ? generateNumber(random) : undefined] + }, + { + name: 'assertMediaContent', + generate: random => [random.bool(0.85) ? messageContent(random) : undefined], + runs: 250 + }, + + // ---- src/WABinary/generic-utils.ts ------------------------------------- + { + name: 'getBinaryNodeChild', + generate: random => [generateBinaryNode(random), random.pick(['error', 'item', 'participant', 'missing', ''])], + runs: 250 + }, + { + name: 'getBinaryNodeChildren', + generate: random => [generateBinaryNode(random), random.pick(['error', 'item', 'participant', 'missing', ''])], + runs: 250 + }, + { name: 'getAllBinaryNodeChildren', generate: random => [generateBinaryNode(random)], runs: 250 }, + // These three read the *content* of the child they find, so the child has to + // carry the tag being queried and bytes to read. Drawing the tag and the + // content independently left every case returning undefined — see + // `generateTaggedNode`. + { + name: 'getBinaryNodeChildBuffer', + generate: random => { + // The queried tag first, then a node built to carry it — see + // `generateTaggedNode`. + const tag = random.pick(CONTENT_TAGS) + return [generateTaggedNode(random, CONTENT_TAGS, tag), tag] + }, + runs: 250 + }, + { + name: 'getBinaryNodeChildString', + generate: random => { + const tag = random.pick(CONTENT_TAGS) + return [generateTaggedNode(random, CONTENT_TAGS, tag), tag] + }, + runs: 250 + }, + { + name: 'getBinaryNodeChildUInt', + generate: random => { + const tag = random.pick(CONTENT_TAGS) + return [generateTaggedNode(random, CONTENT_TAGS, tag), tag, random.pick([1, 2, 3, 4, 8, 0])] + }, + runs: 250 + }, + { + name: 'reduceBinaryNodeToDictionary', + generate: random => [generateDictionaryNode(random), random.pick(['item', 'missing'])], + runs: 250 + }, + { + // Both shapes: `generateErrorNode` always carries an `` child, so on + // its own it only ever exercised the throwing path. + name: 'assertNodeErrorFree', + generate: random => [random.bool(0.8) ? generateResponseNode(random) : generateErrorNode(random)], + runs: 250 + }, + { + name: 'binaryNodeToString', + generate: random => [generateBinaryNode(random) as unknown as BinaryNode['content']], + runs: 200 + }, + { + name: 'getBinaryNodeMessages', + generate: random => [random.bool(0.85) ? generateMessageStanza(random) : generateBinaryNode(random)], + runs: 200, + // Whether the stanza carries a `` child the reference decoder + // cannot read back. The two decoders' documented difference is entirely + // about *malformed* payloads, and without this the allowlist entry could + // only say "exactly one side threw" — which a regression that started + // throwing on a perfectly good stanza also satisfies. Measured over 400 + // draws: all 42 one-sided throws carry at least one unfaithful payload, + // and none carries only faithful ones. + tag: args => (stanzaCarriesUnreadablePayload(args[0]) ? 'malformed payload' : 'well-formed payloads') + }, + + // ---- src/Utils/crypto.ts (the deterministic half) ---------------------- + // Sizes are generated off-spec on purpose: a 31-byte AES key has to fail the + // same way on both sides, and "one throws, the other pads" is a real bug. + { + name: 'aesEncrypWithIV', + generate: random => [cryptoBuffer(random), cryptoKey(random), cryptoIv(random)], + runs: 200 + }, + { + name: 'aesDecryptWithIV', + generate: random => { + if (random.bool(0.3)) return [cryptoBuffer(random), cryptoKey(random), cryptoIv(random)] + const { sealed, key, iv } = cbcTuple(random) + return random.bool(0.75) ? [sealed, key, iv] : [corrupt(random, sealed), key, iv] + }, + runs: 200 + }, + { + name: 'aesDecrypt', + // The IV is the first 16 bytes of the buffer here, so the tuple is prefixed + // rather than passed separately. + generate: random => { + if (random.bool(0.3)) return [cryptoBuffer(random), cryptoKey(random)] + const { sealed, key, iv } = cbcTuple(random) + const framed = Buffer.concat([iv, sealed]) + return random.bool(0.75) ? [framed, key] : [corrupt(random, framed), key] + }, + runs: 200 + }, + { name: 'aesEncryptCTR', generate: random => [cryptoBuffer(random), cryptoKey(random), cryptoIv(random)], runs: 200 }, + { name: 'aesDecryptCTR', generate: random => [cryptoBuffer(random), cryptoKey(random), cryptoIv(random)], runs: 200 }, + { + name: 'aesEncryptGCM', + generate: random => [cryptoBuffer(random), cryptoKey(random), cryptoNonce(random), cryptoBuffer(random)], + runs: 200 + }, + { + name: 'aesDecryptGCM', + // Most cases are a real encrypt-then-decrypt tuple, sometimes with one piece + // corrupted. Generating the four arguments independently means the tag can + // never authenticate, so both sides only ever threw — and the comparator + // reads two throws as agreement, so successful plaintext recovery, which is + // the entire point of the helper, was never compared. + generate: random => { + const key = Buffer.from(random.bytes(32)) + const nonce = Buffer.from(random.bytes(12)) + const additional = cryptoBuffer(random) + const plaintext = cryptoBuffer(random) + const cipher = createCipheriv('aes-256-gcm', key, nonce) + if (additional.length > 0) cipher.setAAD(additional) + const sealed = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()]) + + if (random.bool(0.7)) return [sealed, key, nonce, additional] + // One piece off, so the reject path stays covered too. + return random.pick([ + [corrupt(random, sealed), key, nonce, additional], + [sealed, cryptoKey(random), nonce, additional], + [sealed, key, cryptoNonce(random), additional], + [sealed, key, nonce, cryptoBuffer(random)] + ]) + }, + runs: 200 + }, + { + name: 'hkdf', + generate: random => [ + cryptoBuffer(random), + random.pick([0, 1, 16, 32, 64, 80, 255, 8_160, 8_161]), + random.bool(0.8) + ? { salt: random.bool(0.6) ? cryptoBuffer(random) : undefined, info: generateString(random) } + : {} + ], + runs: 200 + }, + { + name: 'hkdfInfoKey', + generate: random => [ + random.pick(['image', 'video', 'audio', 'document', 'sticker', 'thumbnail-link', 'md-app-state', 'unknown', '']) + ] + }, + { + name: 'hmacSign', + generate: random => [cryptoBuffer(random), cryptoKey(random), random.pick(['sha256', 'sha512', undefined, 'md5'])], + runs: 200 + }, + { name: 'md5', generate: random => [cryptoBuffer(random)], runs: 150 }, + { name: 'sha256', generate: random => [cryptoBuffer(random)], runs: 150 }, + { + name: 'generateSignalPubKey', + generate: random => [ + random.weighted([ + [4, random.bytes(32)], + [3, random.bytes(33)], + [2, generateBytes(random)] + ]) + ], + runs: 150 + }, + + // ---- auth, signal and media helpers ------------------------------------ + { + name: 'assertMeId', + generate: random => [ + random.bool(0.8) + ? { me: random.bool(0.8) ? { id: generateMaybeJid(random) } : undefined } + : generateAnyValue(random) + ] + }, + { + name: 'buildAckStanza', + generate: random => [ + generateBinaryNode(random, 1), + random.bool(0.5) ? generateNumber(random) : undefined, + random.bool(0.5) ? generateMaybeJid(random) : undefined + ], + runs: 200 + }, + { + name: 'cleanMessage', + generate: random => [ + { key: messageKey(random), message: messageContent(random), participant: generateMaybeJid(random) }, + generateJid(random), + generateJid(random) + ], + runs: 200 + }, + { name: 'createSignalIdentity', generate: random => [generateJid(random), generateBytes(random)], runs: 150 }, + { + name: 'decodeMediaRetryNode', + // Mostly well-formed retry stanzas, with the odd error node: feeding it only + // error nodes reached the missing-`rmr` throw and nothing else. + generate: random => [random.bool(0.85) ? generateMediaRetryNode(random) : generateErrorNode(random)], + runs: 200 + }, + { + name: 'extractDeviceJids', + // The helper drops the caller's own device: it keeps a row only when + // `(myUser !== user && myLid !== user) || myDevice !== device`. With the + // query rows and the `myJid`/`myLid` arguments drawn independently, that + // branch was never reached — measured at 0 of 366 generated device rows on + // the fixed seed — so a regression that handed the caller its own device + // back alongside its peers' stayed green. + // + // So the current identity is planted into some rows. Both device numbers + // matter: the exclusion needs the user *and* the device to match, so a row + // with the right user and a different device is the near-miss that must + // still be kept, and it is the pair that pins the `&&`. + generate: random => { + const myJid = generateJid(random) + // `myLid` is compared raw against each row's *decoded user part* + // (`myLid !== user`), while every call site in the repository passes a + // full JID — so for well-formed input that half of the guard can never + // be false, in either library. It is reachable only with a bare user, + // which is why one is drawn some of the time: off-contract, but the only + // input that executes the branch, and both sides should still agree on it. + const myLid = random.bool(0.7) ? generateJid(random) : generateJid(random).split('@')[0]! + const myLidUser = myLid.split('@')[0]! + const myUser = myJid.split('@')[0]! + const myDevice = Number(myUser.split(':')[1] ?? 0) + const rows = Array.from({ length: random.int(0, 3) }, () => ({ + // The caller's own phone-number identity, the caller's own LID, or a + // peer. Both identities on purpose: the guard reads + // `myUser !== user && myLid !== user`, so rows that only ever matched + // `myJid` left the second half untested and an implementation that + // excluded the phone number alone still passed. + id: random.weighted([ + [3, myJid], + // A well-formed JID whose *user part* is the caller's LID identity. + // Passing `myLid` itself would not do: the guard compares it against + // each row's decoded user, so the row has to carry that user, and a + // bare string is not a JID the decoder accepts as a row id at all. + [2, `${myLidUser}@lid`], + [5, generateJid(random)] + ]), + devices: { + deviceList: Array.from({ length: random.int(0, 3) }, () => ({ + // And within such a row, the caller's own device number as often as + // any other — that is the difference between reaching the branch + // and merely reaching the row. + id: random.bool(0.4) ? myDevice : random.int(0, 5), + keyIndex: random.int(0, 5), + isHosted: random.bool() + })) + } + })) + return [rows, myJid, myLid, random.bool()] + }, + runs: 200 + }, + { + name: 'extractE2ESessionFromRetryReceipt', + // A real key bundle most of the time: the generic generator has no `keys` + // child, so every input returned null at the first lookup. + generate: random => [random.bool(0.85) ? generateRetryReceiptNode(random) : generateBinaryNode(random)], + runs: 200 + }, + { name: 'getChatId', generate: random => [messageKey(random)], runs: 200 }, + { + name: 'isRealMessage', + generate: random => [ + { + key: messageKey(random), + message: messageContent(random), + messageStubType: random.bool(0.4) ? random.int(0, 80) : undefined + } + ], + runs: 200 + }, + { + name: 'shouldIncrementChatUnread', + generate: random => [ + { + key: messageKey(random), + message: messageContent(random), + messageStubType: random.bool(0.4) ? random.int(0, 80) : undefined + } + ], + runs: 200 + }, + { + name: 'getHistoryMsg', + generate: random => [random.bool(0.8) ? historyNotificationContent(random) : messageContent(random)], + runs: 200 + }, + { + name: 'getPlatformId', + generate: random => [random.pick(['Chrome', 'Firefox', 'Safari', 'Edge', 'Opera', 'Desktop', '', 'unknown'])] + }, + { + name: 'getCompanionPlatformId', + generate: random => [ + [ + random.pick(['Ubuntu', 'Mac OS', 'Windows', '']), + random.pick(['Chrome', 'Firefox', 'Safari', '']), + random.pick(['110.0', '']) + ] + ] + }, + { + name: 'getCompanionWebClientType', + generate: random => [ + [ + random.pick(['Ubuntu', 'Mac OS', 'Windows', '']), + random.pick(['Chrome', 'Firefox', 'Safari', 'Edge', 'Opera', '']), + random.pick(['110.0', '']) + ] + ] + }, + { name: 'getStatusCodeForMediaRetry', generate: random => [generateNumber(random)] }, + { + name: 'getUrlFromDirectPath', + generate: random => [generateString(random), random.bool(0.5) ? generateString(random) : undefined] + }, + { name: 'extensionForMediaMessage', generate: random => [messageContent(random)], runs: 200 }, + { + name: 'mediaMessageSHA256B64', + generate: random => [random.bool(0.85) ? mediaWithDigest(random) : messageContent(random)], + runs: 200 + }, + { name: 'encodeBase64EncodedStringForUpload', generate: random => [uploadBase64(random)], runs: 200 }, + { + name: 'xmppPreKey', + generate: random => [{ public: generateBytes(random), private: generateBytes(random) }, generateNumber(random)], + runs: 150 + }, + { + name: 'xmppSignedPreKey', + generate: random => [ + { + keyPair: { public: generateBytes(random), private: generateBytes(random) }, + signature: generateBytes(random), + keyId: generateNumber(random) + } + ], + runs: 150 + }, + { name: 'extractUrlFromText', generate: random => [urlText(random)], runs: 250 }, + { + name: 'generateForwardMessageContent', + generate: random => [ + { key: messageKey(random), message: messageContent(random) }, + random.bool(0.5) ? random.bool() : undefined + ], + runs: 200, + // Whether the whole difference is the copy strategy, answered by running + // that strategy rather than describing it. + // + // The two implementations differ only in how they copy: upstream rebuilds + // content through `proto.Message.decode(proto.Message.encode(content))` + // while baileyrs shallow-clones. So the difference is explained exactly when + // upstream's result *is* that round trip of baileyrs' result — which the + // registry cannot compute, having no protobuf runtime. + // + // This replaced a structural rule that tried to describe the round trip's + // effects from outside, and got them wrong in both directions: it excused a + // dropped `extendedTextMessage.text` — a declared field, real body loss — + // and once tightened it reported a `deviceSentMessage` carrying an + // undeclared property as unexplained. Only the schema separates those two, + // and the round trip *is* the schema. Measured: upstream equals the round + // trip for both of those, and does not for a simulated body drop or a + // changed value. + tag: (_args, mine, theirs) => { + if (mine === undefined || theirs === undefined) return undefined + const copied = attemptRoundTrip(mine) + if (copied === undefined) return undefined + return JSON.stringify(copied) === JSON.stringify(theirs) ? 'copy strategy' : 'not the copy strategy' + } + }, + { + name: 'getAggregateVotesInPollMessage', + generate: random => [pollWithVotes(random), random.bool(0.6) ? generateMaybeJid(random) : undefined], + runs: 200 + }, + { + name: 'getAggregateResponsesInEventMessage', + // The aggregator reads `eventResponse` off each update — a convenience field + // the runtime attaches after decryption, deliberately absent from the wire + // protobuf. Generating the wire shape instead meant every update read as + // UNKNOWN and the GOING / NOT_GOING / MAYBE buckets were never filled. + generate: random => [ + { + eventResponses: Array.from({ length: random.int(0, 4) }, () => ({ + eventResponseMessageKey: messageKey(random), + eventResponse: random.pick(['GOING', 'NOT_GOING', 'MAYBE', 'UNKNOWN', '', undefined]), + timestampMs: generateNumber(random) + })) + }, + random.bool(0.6) ? generateMaybeJid(random) : undefined + ], + runs: 200 + }, + { + name: 'updateMessageWithEventResponse', + // The defining branch is the replacement: it filters out any existing + // response from the same author before appending. With the list always empty + // only the append ran, so a regression that left duplicates from one author, + // or dropped a different author's response, compared equal. + generate: random => { + const author = messageKey(random) + return [ + { + eventResponses: random.bool(0.75) + ? [ + { eventResponseMessageKey: random.bool(0.6) ? author : messageKey(random), eventResponse: 'GOING' }, + ...(random.bool(0.4) ? [{ eventResponseMessageKey: messageKey(random), eventResponse: 'MAYBE' }] : []) + ] + : random.pick([[], undefined]) + }, + { + eventResponseMessageKey: author, + eventResponse: random.pick(['GOING', 'NOT_GOING', 'MAYBE', undefined]), + timestampMs: generateNumber(random) + } + ] + } + }, + { name: 'getCodeFromWSError', generate: random => [wsError(random)], runs: 200 } +] + +const targetNames = TARGETS.map(target => target.name) + +describe('pure helper differential — baileyrs vs baileys', () => { + it('covers every helper this fuzzer claims to cover', () => { + const missing = targetNames.filter( + name => typeof local[name] !== 'function' || typeof upstream[name] !== 'function' + ) + if (missing.length > 0) { + throw new Error(`not a shared function export in both packages: ${missing.join(', ')}`) + } + const undeclared = targetNames.filter(name => !PURE_TARGET_NAMES.includes(name)) + if (undeclared.length > 0) { + throw new Error(`add to PURE_TARGET_NAMES in targets.ts: ${undeclared.join(', ')}`) + } + // And the reverse. Without this, adding a name to PURE_TARGET_NAMES would + // satisfy the coverage ledger while the helper is never actually called — + // the ledger would be recording an intention rather than a fact. + const covered = new Set(targetNames) + const claimed = PURE_TARGET_NAMES.filter(name => !covered.has(name)) + if (claimed.length > 0) { + throw new Error(`listed in PURE_TARGET_NAMES but no fuzz case builds arguments for them: ${claimed.join(', ')}`) + } + }) + + for (const target of TARGETS) { + it(`${target.name}`, async () => { + const localFunction = local[target.name] as (...args: unknown[]) => unknown + const upstreamFunction = upstream[target.name] as (...args: unknown[]) => unknown + + await fuzz({ + target: `pure:${target.name}`, + runs: target.runs ?? 150, + // The argument list keeps its length while shrinking: a report about + // calling a two-argument helper with one argument answers a question + // nobody asked. + shrinkRoot: false, + generate: target.generate, + check: args => { + // Each side gets its own copy: several of these helpers mutate their + // first argument, and that mutation is as much a contract as the + // return value — so both are compared. + const localArgs = clone(args) as unknown[] + const upstreamArgs = clone(args) as unknown[] + + const localOutcome = runOutcome(() => localFunction(...localArgs)) + const upstreamOutcome = runOutcome(() => upstreamFunction(...upstreamArgs)) + + const findings: Divergence[] = [] + + // `coerceScalars: false`: for a plain helper, returning the number 123 + // where upstream returns the string "123" is a real API difference — + // `typeof`, `===` and arithmetic all expose it. The integer coercion + // exists so a Rust u64 can be compared against a protobufjs Long, and + // that is the codec fuzzer's problem, not this one's. + // + // `preservePresence: true` for the same reason one level down: a key + // deleted and a key left holding `undefined` are different objects to + // `Object.keys`, spread and `in`. Collapsing them made the mutation + // check unable to see one side deleting a property — and made + // `trimUndefined`, whose whole job is that deletion, untestable here. + const strict = { coerceScalars: false, preservePresence: true } + // The two results as well as the arguments: a classification like "is this + // difference the documented copy strategy" is a statement about the + // outputs, and only this file has the protobuf runtime to answer it. + const tag = target.tag?.( + args, + localOutcome.kind === 'return' ? localOutcome.value : undefined, + upstreamOutcome.kind === 'return' ? upstreamOutcome.value : undefined + ) + const withTag = (detail: string) => (tag === undefined ? detail : `${detail} [${tag}]`) + const comparison = compareOutcomes(localOutcome, upstreamOutcome, strict) + if (!comparison.same) { + findings.push({ + target: `pure:${target.name}`, + input: args, + local: showOutcome(localOutcome), + upstream: showOutcome(upstreamOutcome), + detail: withTag(comparison.detail ?? 'return values differ') + }) + } + + const mutation = compareOutcomes( + { kind: 'return', value: localArgs }, + { kind: 'return', value: upstreamArgs }, + strict + ) + if (!mutation.same) { + findings.push({ + target: `pure:${target.name}#mutation`, + input: args, + local: localArgs, + upstream: upstreamArgs, + detail: withTag('the helpers left their arguments in different states') + }) + } + + return findings + } + }) + }) + } +}) diff --git a/src/__fuzz__/targets.ts b/src/__fuzz__/targets.ts new file mode 100644 index 00000000..f1f542ba --- /dev/null +++ b/src/__fuzz__/targets.ts @@ -0,0 +1,233 @@ +/** + * The coverage ledger for the differential fuzzers. + * + * baileyrs and Baileys share 150-odd function exports. A fuzz suite that quietly + * covers thirty of them and says nothing about the rest is worse than none — it + * reads as assurance it does not provide. So every shared export has to appear + * in exactly one of these two lists, and `coverage.fuzz.test.ts` fails when a new + * one appears in neither. + * + * That turns "we should fuzz the new helper" from a thing somebody remembers into + * a red test on the pull request that adds it. + */ + +/** + * Helpers driven by `pure-differential.fuzz.test.ts`. + * + * This list is the claim; the evidence is in that file, which asserts both + * directions against the generators it actually defines — a name here with no + * generator fails, and a generator with no name here fails too. The check has to + * live there rather than in `coverage.fuzz.test.ts`, because `node --test` runs + * every file in its own process: nothing a fuzz file records at runtime is + * visible to another one. + */ +export const PURE_TARGET_NAMES: readonly string[] = [ + // src/WABinary/jid-utils.ts + 'jidDecode', + 'jidEncode', + 'jidNormalizedUser', + 'areJidsSameUser', + 'transferDevice', + 'getServerFromDomainType', + 'isJidBot', + 'isJidBroadcast', + 'isJidGroup', + 'isJidMetaAI', + 'isJidNewsletter', + 'isJidStatusBroadcast', + 'isLidUser', + 'isPnUser', + 'isHostedLidUser', + 'isHostedPnUser', + + // src/Utils/generics.ts + 'encodeBigEndian', + 'unpadRandomMax16', + 'toNumber', + 'isStringNullOrEmpty', + 'getKeyAuthor', + 'getStatusFromReceiptType', + 'getCallStatusFromNode', + 'getErrorCodeFromStreamError', + 'getCodeFromWSError', + 'isWABusinessPlatform', + 'bytesToCrockford', + 'trimUndefined', + 'unixTimestampSeconds', + 'generateParticipantHashV2', + 'encodeNewsletterMessage', + + // src/Utils/messages.ts + 'getContentType', + 'normalizeMessageContent', + 'extractMessageContent', + 'getDevice', + 'aggregateMessageKeysNotFromMe', + 'hasNonNullishProperty', + 'updateMessageWithReceipt', + 'updateMessageWithReaction', + 'updateMessageWithPollUpdate', + 'updateMessageWithEventResponse', + 'prepareDisappearingMessageSettingContent', + 'assertMediaContent', + 'extractUrlFromText', + 'generateForwardMessageContent', + 'getAggregateVotesInPollMessage', + 'getAggregateResponsesInEventMessage', + + // src/WABinary/generic-utils.ts + 'getBinaryNodeChild', + 'getBinaryNodeChildren', + 'getAllBinaryNodeChildren', + 'getBinaryNodeChildBuffer', + 'getBinaryNodeChildString', + 'getBinaryNodeChildUInt', + 'reduceBinaryNodeToDictionary', + 'assertNodeErrorFree', + 'binaryNodeToString', + 'getBinaryNodeMessages', + + // src/Utils/crypto.ts — the deterministic half + 'aesDecrypt', + 'aesDecryptCTR', + 'aesDecryptGCM', + 'aesDecryptWithIV', + 'aesEncrypWithIV', + 'aesEncryptCTR', + 'aesEncryptGCM', + 'hkdf', + 'hkdfInfoKey', + 'hmacSign', + 'md5', + 'sha256', + 'generateSignalPubKey', + + // src/Utils/messages-media.ts, process-message.ts, signal.ts, auth-utils.ts + 'assertMeId', + 'buildAckStanza', + 'cleanMessage', + 'createSignalIdentity', + 'decodeMediaRetryNode', + 'extractDeviceJids', + 'extractE2ESessionFromRetryReceipt', + 'getChatId', + 'isRealMessage', + 'shouldIncrementChatUnread', + 'getHistoryMsg', + 'getPlatformId', + 'getCompanionPlatformId', + 'getCompanionWebClientType', + 'getStatusCodeForMediaRetry', + 'getUrlFromDirectPath', + 'extensionForMediaMessage', + 'mediaMessageSHA256B64', + 'encodeBase64EncodedStringForUpload', + 'xmppPreKey', + 'xmppSignedPreKey' +] + +/** + * Shared exports the differential fuzzers deliberately leave alone, each with the + * reason. "Not fuzzed" is a claim like any other: it should be readable, and it + * should be wrong-able. + */ +export const EXCLUDED_EXPORTS: Readonly> = { + // Non-deterministic: the two implementations cannot agree on a random draw. + aesEncrypt: 'generates a random IV; aesEncrypWithIV covers the deterministic half', + encodeWAMessage: + 'appends writeRandomPadMax16 padding, so two calls never agree; encodeNewsletterMessage fuzzes the same encoder unpadded', + writeRandomPadMax16: 'pads with random bytes; unpadRandomMax16 covers the inverse', + generateMessageID: 'draws a random message id on every call', + generateMessageIDV2: 'draws a random message id on every call', + generateMdTagPrefix: 'draws a random tag prefix on every call', + generateRegistrationId: 'draws a random registration id on every call', + initAuthCreds: 'generates fresh key material on every call', + signedKeyPair: 'generates fresh key material on every call', + getPreKeys: 'generates fresh key material on every call', + buildPairingQRData: 'derives from live pairing state and fresh key material', + + // I/O, network or filesystem: not a pure comparison, and covered by e2e. + fetchLatestBaileysVersion: 'fetches the version manifest over the network', + fetchLatestWaWebVersion: 'fetches the version manifest over the network', + getUrlInfo: 'fetches the target page over the network', + getHttpStream: 'opens an HTTP connection', + getStream: 'consumes a readable stream, so the input is not a value', + toReadable: 'produces a stream, which cannot be deep-compared', + toBuffer: 'consumes a readable stream, so the input is not a value', + useMultiFileAuthState: 'reads and writes auth state on the filesystem', + downloadMediaMessage: 'network + media pipeline; covered by the e2e suite', + downloadContentFromMessage: 'network + media pipeline; covered by the e2e suite', + downloadHistory: 'network + media pipeline; covered by the e2e suite', + downloadAndProcessHistorySyncNotification: 'network + media pipeline; covered by the e2e suite', + prepareWAMessageMedia: 'uploads media; covered by the e2e suite', + generateLinkPreviewIfRequired: 'network fetch; covered by link-preview-compatibility', + generateThumbnail: 'image codec; covered by the e2e suite', + extractImageThumb: 'image codec; covered by the e2e suite', + generateProfilePicture: 'image codec; covered by the e2e suite', + getAudioDuration: 'audio codec', + getAudioWaveform: 'audio codec', + getMediaKeys: 'async HKDF wrapper; hkdf itself is fuzzed directly', + encryptMediaRetryRequest: 'async and keyed by live session state', + derivePairingCodeKey: 'async PBKDF2; pinned by crypto-compatibility', + + // Timers, events and other stateful machinery. + delay: 'resolves on a timer, so the observable behaviour is wall-clock', + delayCancellable: 'resolves on a timer, so the observable behaviour is wall-clock', + promiseTimeout: 'races against a timer, so the observable behaviour is wall-clock', + debouncedTimeout: 'schedules work on a timer and returns a live handle', + bindWaitForEvent: 'subscribes to an emitter and resolves on a future event', + bindWaitForConnectionUpdate: 'subscribes to an emitter and resolves on a future event', + // Scoped deliberately. `bridge-events.fuzz.test.ts` drives `emit`, `buffer` + // and `flush` differentially against upstream, which is the consolidation + // logic and the bulk of the surface. `createBufferedFunction` is *not* + // covered: its distinguishing behaviour is a nesting count released in a + // `finally` and an automatic flush 100ms after the last concurrent job + // settles, and every property of it that can be asserted without racing that + // timer is either already implied by the emit/buffer/flush targets or + // vacuous — `flush()` resets `buffering` unconditionally, so asserting + // `isBuffering()` after a flush can never fail. A differential over it was + // written and withdrawn: it surfaced several distinct consolidation + // differences (groups.update entries with no id, blank fields kept on one + // side) that are real findings needing characterisation, and landing it would + // have meant an allowlist entry broad enough to excuse whatever else turned + // up. `Socket/groups.ts` and `Socket/internals.ts` use the method in + // production, so this gap is worth closing — in a change that can do it + // justice. Also listed under "What this does not cover" in README.md, because + // a comment in an exclusion table is not somewhere anyone planning work looks. + makeEventBuffer: 'emit/buffer/flush covered by bridge-events.fuzz.test.ts; createBufferedFunction is not', + makeCacheableSignalKeyStore: 'stateful keystore; covered by the store test suite', + addTransactionCapability: 'stateful keystore wrapper; covered by the store test suite', + handleIdentityChange: 'mutates live session state', + MessageRetryManager: 'class with internal state; covered by public-helpers-compatibility', + makeWASocket: 'socket factory; covered by the e2e suite', + default: 'the makeWASocket default export', + + // Classes whose contract is pinned by the dedicated USync suites. + USyncQuery: 'class; covered by usync-compatibility', + USyncUser: 'class; covered by usync-compatibility', + USyncContactProtocol: 'class; covered by usync-compatibility', + USyncDeviceProtocol: 'class; covered by usync-compatibility', + USyncDisappearingModeProtocol: 'class; covered by usync-compatibility', + USyncStatusProtocol: 'class; covered by usync-compatibility', + USyncUsernameProtocol: 'class; covered by usync-compatibility', + + // Message construction. Only the synchronous builder is actually fuzzed: + // wire-fidelity.fuzz.test.ts calls generateWAMessageFromContent on both sides + // and diffs the envelope, which is a stronger oracle than argument diffing. + generateWAMessageFromContent: 'send-path builder; covered by wire-fidelity.fuzz.test.ts', + // The other two are not covered by anything here, and saying they were is + // worse than the gap: it retires a target nothing exercises. Both are async + // and reach media upload, so a differential needs a stubbed upload pair that + // returns identically on both sides before the comparison means anything — + // otherwise every draw diverges on an upload URL. Worth building; not here. + generateWAMessage: 'NOT COVERED: async, uploads media; needs a paired upload stub first', + generateWAMessageContent: 'NOT COVERED: async, uploads media; needs a paired upload stub first', + processHistoryMessage: 'history-sync pipeline; covered by history-sync-inflate and the wire tests', + + // Keyed crypto whose inputs cannot be generated meaningfully: random input only + // ever reaches the shared "reject" branch, so the differential proves nothing. + decryptPollVote: 'needs real poll key material; covered by the message compatibility suite', + decryptEventResponse: 'needs real event key material; covered by the message compatibility suite', + decryptMediaRetryData: 'needs real retry key material; covered by the media retry suite', + parseAndInjectE2ESessions: 'needs a live signal repository; covered by prekey-compatibility' +} diff --git a/src/__fuzz__/wire-fidelity.fuzz.test.ts b/src/__fuzz__/wire-fidelity.fuzz.test.ts new file mode 100644 index 00000000..a65d081a --- /dev/null +++ b/src/__fuzz__/wire-fidelity.fuzz.test.ts @@ -0,0 +1,399 @@ +/** + * Send-path wire fidelity, on generated messages. + * + * `scripts/compatibility/wire-fidelity-core.ts` already asks this question — does + * a field planted on a message survive `relayMessage` and reach the bridge — but + * it asks it about a list of cases somebody wrote down, one field at a time. That + * misses the failure it was built to catch most: a send path that preserves every + * field in isolation and loses one when a particular neighbour is present. + * + * Here the messages come from the schema, arbitrarily shaped and arbitrarily + * deep, and the oracle is the whole message rather than a list of paths: + * + * fidelity everything in a plain encode of the input is still in the bytes + * the bridge received — additions are fine, losses are not + * readability upstream protobufjs can read those bytes and finds the same + * fields, so a message baileyrs sends is one Baileys could parse + * builder generateWAMessageFromContent keeps the content it was given + * + * "Additions are fine" is deliberate: the send path legitimately attaches device + * metadata and ephemeral wrappers, and a fuzzer that called those a divergence + * would be unusable. + */ + +import { describe, it } from 'node:test' +import { decodeProto, encodeProto } from '@oxidezap/whatsapp-rust-bridge' +import { equivalent, normalise, omitsKeysOnly } from './harness/compare.ts' +import type { Divergence } from './harness/divergence.ts' +import { fuzz } from './harness/runner.ts' +import { relayedBytes } from './harness/send-path.ts' +import { generateProtoObject, textFieldPredicate } from './generators/proto.ts' +import { generateJid } from './generators/jid.ts' +import type { Random } from './harness/random.ts' + +const upstream = (await import('baileys')) as unknown as { + proto: { + Message: { + decode(bytes: Uint8Array): unknown + toObject(message: unknown, options: Record): Record + } + } + generateWAMessageFromContent: (jid: string, content: unknown, options: Record) => unknown +} + +const local = (await import('../index.ts')) as unknown as { + generateWAMessageFromContent: (jid: string, content: unknown, options: Record) => unknown +} + +const TO_OBJECT = { longs: String, enums: Number, defaults: false, arrays: false, objects: false, oneofs: false } + +/** + * Everything in `reference` is present and equal in `actual`; extras are allowed. + * + * Schema-aware, like every other comparison in this file: both sides are rooted + * at `Message`, and without the predicate the default coercion folds a declared + * string field holding `'0'` together with the number `0`. That is the readability + * regression these targets exist to catch, and it would have passed here as + * "preserved" — `generateProtoObject` emits `'0'` on purpose. + */ +const preserves = (actual: unknown, reference: unknown): boolean => { + const shape = { isTextField: textFieldPredicate('Message') } + return equivalent(actual, reference, shape) || omitsKeysOnly(reference, actual, shape) +} + +interface WireCase { + readonly jid: string + readonly message: Record + /** The message being replied to, for the builder's quoted-message branch. */ + readonly quoted?: Record + /** Disappearing-message expiry, for the builder's ephemeral branch. */ + readonly ephemeralExpiration?: number +} + +const isUsable = (value: WireCase): boolean => + typeof value?.jid === 'string' && typeof value.message === 'object' && value.message !== null + +/** + * A message to quote, shaped the way the builder reads one. + * + * `generateWAMessageFromContent` resolves the quoted participant as + * `key.fromMe ? userJid : quoted.participant || key.participant || key.remoteJid` + * and then decides `remoteJid` by whether the quoted chat matches the target — + * so the key needs all four of those fields to vary, and the quoted chat has to + * sometimes differ from the JID being sent to. + */ +const quotedMessage = (random: Random): Record => ({ + key: { + remoteJid: random.weighted([ + [3, '120363000000000000@g.us'], + [2, '15551234567@s.whatsapp.net'], + [1, generateJid(random)] + ]), + fromMe: random.bool(), + id: random.pick(['3EB0QUOTED000000', 'BAE5' + 'A'.repeat(12), '']), + participant: random.bool(0.5) ? generateJid(random) : undefined + }, + participant: random.bool(0.3) ? generateJid(random) : undefined, + // The builder normalises this through `normalizeMessageContent` and then keeps + // exactly one content key, so a wrapper here exercises that unwrapping too. + message: generateProtoObject(random, 'Message', 2, { fieldProbability: 0.5 }) +}) + +const generateCase = (random: Random): WireCase => ({ + // Group, DM, newsletter and broadcast take different branches through the send + // path, and a field lost on only one of them is exactly what a single-jid test + // would never see. + jid: random.weighted([ + [4, '120363000000000000@g.us'], + [3, '15551234567@s.whatsapp.net'], + [2, '120363000000000000@newsletter'], + [1, 'status@broadcast'], + [1, generateJid(random)] + ]), + message: generateProtoObject(random, 'Message', 3, { fieldProbability: 0.35 }), + // Present about a third of the time each, and independently: the two branches + // are separate in the builder and both have to be reachable on their own as + // well as together. + ...(random.bool(0.35) ? { quoted: quotedMessage(random) } : {}), + ...(random.bool(0.35) + ? // Zero is in the pool on purpose: it is falsy, so `!!ephemeralExpiration` + // rejects it and the branch is skipped — which is the behaviour, and the + // `|| WA_DEFAULT_EPHEMERAL` fallback below it never fires for that reason. + { ephemeralExpiration: random.pick([0, 1, 86_400, 604_800, 7_776_000, -1]) } + : {}) +}) + +describe('send-path wire fidelity on generated messages', () => { + it('hands the bridge everything the message carried', async () => { + await fuzz({ + target: 'wire:fidelity', + runs: 250, + generate: generateCase, + check: async value => { + if (!isUsable(value)) return [] + const { jid, message } = value + + // A plain encode/decode of the same input, through the same codec — so + // anything that differs is the send path's doing and not the codec's. + let reference: unknown + try { + // The reference encode gets its own copy: `relayMessage` may mutate the + // message it is handed, and a reference built from a mutated object + // would compare the send path against its own output. + reference = decodeProto('Message', encodeProto('Message', structuredClone(message))) + } catch { + // The codec cannot carry this message at all; that is the codec + // fuzzer's subject, not this one's. + return [] + } + + let sentBytes: Uint8Array + try { + sentBytes = await relayedBytes(message, { jid }) + } catch (error) { + return { + target: 'wire:fidelity', + input: { jid, message }, + local: ``, + upstream: '', + detail: 'the send path produced no bytes for a message the codec accepts' + } + } + + let sent: unknown + try { + sent = decodeProto('Message', sentBytes) + } catch (error) { + return { + target: 'wire:fidelity', + input: { jid, message }, + local: ``, + upstream: normalise(reference), + detail: 'baileyrs cannot read back the bytes it handed the bridge' + } + } + if (preserves(sent, reference)) return [] + + return { + target: 'wire:fidelity', + input: { jid, message }, + local: normalise(sent), + upstream: normalise(reference), + detail: 'the send path dropped or altered part of the message' + } + } + }) + }) + + it('sends bytes upstream Baileys can read', async () => { + await fuzz({ + target: 'wire:upstream-readable', + runs: 250, + generate: generateCase, + check: async value => { + if (!isUsable(value)) return [] + const { jid, message } = value + + let sentBytes: Uint8Array + try { + sentBytes = await relayedBytes(message, { jid }) + } catch (error) { + // Reported here too, not deferred to `wire:fidelity`. + // + // "Covered next door" assumed both targets see the same inputs. They + // do not: the runner seeds each target `${FUZZ_SEED}:${target}`, so + // the two draw entirely different JID and message combinations, and a + // send-path failure reachable only from this target's stream was + // dropped on the floor. The duplicate-report concern it was written + // for cannot arise for the same reason. + return { + target: 'wire:upstream-readable', + input: { jid, message }, + local: ``, + upstream: '', + detail: 'the send path produced no bytes for a message the codec accepts' + } + } + + const findings: Divergence[] = [] + let upstreamView: Record + try { + upstreamView = upstream.proto.Message.toObject(upstream.proto.Message.decode(sentBytes), TO_OBJECT) + } catch (error) { + return { + target: 'wire:upstream-readable', + input: { jid, message }, + local: Buffer.from(sentBytes).toString('hex').slice(0, 200), + upstream: ``, + detail: 'baileyrs sent bytes upstream Baileys cannot parse' + } + } + + // Field *numbers* are the contract between the two: a field placed at a + // different number still decodes, just into something else. Comparing + // what upstream sees against what the bridge sees catches that. + let bridgeView: unknown + try { + bridgeView = decodeProto('Message', sentBytes) + } catch (error) { + // The bridge refusing to read back bytes upstream just parsed is a + // finding, not a crash in the fuzzer — most often the 2^53 decode + // ceiling, which the known-divergence registry already tracks. + return { + target: 'wire:upstream-readable', + input: { jid, message }, + local: ``, + upstream: normalise(upstreamView), + detail: 'baileyrs cannot read back the bytes it handed the bridge' + } + } + // Both views decode the *same* bytes, so neither side can legitimately + // hold a field the other lacks — which makes exact equality the right + // test in both directions. + // + // The bridge-side superset was the one previously let through, and it is + // the case this target is named for: a field the bridge writes at a + // number upstream does not declare is discarded by upstream as unknown + // and read straight back by the bridge, so a subset check in that + // direction reports nothing. The reverse is data the library sent and + // cannot read back, which the fidelity check above cannot catch either + // — its reference goes through the same bridge encode/decode pair and + // lacks the field too. + // Schema-aware, as the codec differentials are. `normalise` folds every + // decimal string into a bigint so a Rust u64 can be compared against a + // protobufjs Long, which also folds a declared string holding `'0'` + // together with the number `0` — and `generateProtoObject` emits `'0'` + // deliberately. A bridge decoder that returned a number where upstream + // returns the string is exactly the readability regression this target is + // named for. + if (!equivalent(bridgeView, upstreamView, { isTextField: textFieldPredicate('Message') })) { + findings.push({ + target: 'wire:upstream-readable', + input: { jid, message }, + local: normalise(bridgeView), + upstream: normalise(upstreamView), + detail: preserves(bridgeView, upstreamView) + ? 'the bridge read fields from the sent bytes that upstream discards as unknown' + : 'upstream read fields from the sent bytes that the bridge does not return' + }) + } + return findings + } + }) + }) + + it('builds the same message from content as upstream does', async () => { + await fuzz({ + target: 'wire:message-builder', + runs: 250, + generate: generateCase, + check: value => { + if (!isUsable(value)) return [] + const { jid, message } = value + + // A fixed timestamp and message id: everything else about this call is + // deterministic, and without them the two sides differ on the clock. + // + // Plus the two options that decide the builder's other branches. With + // only the three deterministic ones, the quoted-message and ephemeral + // paths in `Utils/messages.ts` were never entered on either side — so + // a regression that dropped `quotedMessage`, wrote the wrong quoted + // participant, or omitted the expiration stayed green while this export + // was recorded as fuzz-covered. Both carry their own exceptions + // (newsletters take neither; protocol and already-ephemeral content + // skip the expiration), and the JID generator produces newsletter JIDs, + // so those exceptions are reached too. + // Rebuilt per call, not spread from one object. `{ ...options }` copies + // the option bag but shares the `quoted` object inside it, and the + // upstream builder *mutates* what it is handed — measured, it deletes + // `contextInfo` from the quoted message. Whichever side ran first would + // hand the other an already-stripped quote, so a regression that + // stopped stripping could not be seen. The main message is deep-cloned + // for exactly this reason; the quote needs the same treatment. + const optionsFor = () => ({ + userJid: '15550000000@s.whatsapp.net', + messageId: '3EB0FUZZ0000000000', + timestamp: new Date(1_700_000_000_000), + ...(value.quoted === undefined ? {} : { quoted: structuredClone(value.quoted) }), + ...(value.ephemeralExpiration === undefined ? {} : { ephemeralExpiration: value.ephemeralExpiration }) + }) + + // Upstream first: if it rejects the generated shape, there is nothing to + // compare against and the input says nothing about baileyrs. + let upstreamBuilt: unknown + try { + upstreamBuilt = upstream.generateWAMessageFromContent(jid, structuredClone(message), optionsFor()) + } catch { + return [] + } + + // A local-only throw is the divergence, not a reason to skip: upstream + // built an envelope from this content and baileyrs did not. Swallowing + // it would contradict the oracle's own rule that throwing is part of + // the contract. + let localBuilt: unknown + try { + localBuilt = local.generateWAMessageFromContent(jid, structuredClone(message), optionsFor()) + } catch (error) { + return { + target: 'wire:message-builder', + input: { jid, message }, + local: ``, + upstream: '', + detail: 'baileyrs could not build a message upstream built' + } + } + + const strip = (built: unknown): unknown => { + let record: Record + try { + record = structuredClone(built) as Record + } catch { + // A builder output that will not clone is not something to crash the + // runner over; compare what can be read instead. + record = { ...(built as Record) } + } + // Nothing is stripped. `messageTimestamp` looked like a clock read and + // was deleted, but both calls are given the same fixed `timestamp` + // option and both honour it — measured, both return 1700000000. + // Dropping it meant a builder that ignored, rounded or re-derived the + // caller's timestamp compared equal, in the one target that claims to + // compare the generated envelope. + // + // Returned raw. Normalising here ran *without* the schema, folding + // every decimal string into a bigint before the schema-aware + // comparison below could see it — so the predicate the next block + // carefully passes had nothing left to distinguish, and `'0'` versus + // `0` on a declared text field read as agreement. Normalisation + // belongs to the comparison, which knows the schema; this only clones. + return record + } + + // Compared against the schema, not just structurally. `normalise` folds + // every decimal string into a bigint so a 64-bit field can be compared + // across the two runtimes, which also folds a *declared string* holding + // `"0"` together with the number `0` — measured on + // `message.conversation`, equivalent without the predicate and not + // equivalent with it. This is the suite's only coverage of + // `generateWAMessageFromContent`, so a builder that changed such a + // field's runtime type had nowhere else to be caught. + const isTextField = textFieldPredicate('WebMessageInfo') + const localView = strip(localBuilt) + const upstreamView = strip(upstreamBuilt) + if (equivalent(localView, upstreamView, { isTextField })) return [] + + return { + target: 'wire:message-builder', + input: { jid, message }, + // Normalised here instead, under the same rules the gate used, so the + // report shows the two sides exactly as the comparison saw them — a + // raw builder output carries Longs and byte arrays that render as + // `{}` through the reporter's own stringifier. + local: normalise(localView, 0, { isTextField }), + upstream: normalise(upstreamView, 0, { isTextField }), + detail: 'generateWAMessageFromContent produced a different envelope' + } + } + }) + }) +}) diff --git a/src/__tests__/event-buffer-compatibility.test.ts b/src/__tests__/event-buffer-compatibility.test.ts index 6872151b..b5b69543 100644 --- a/src/__tests__/event-buffer-compatibility.test.ts +++ b/src/__tests__/event-buffer-compatibility.test.ts @@ -125,4 +125,40 @@ describe('event buffer — upstream process() contract', () => { ev.flush() expect(sets[0]?.pastParticipants?.[0]?.pastParticipants?.map(item => item.userJid)).toEqual(['1@lid', '2@lid']) }) + /** + * An id-less chat is not the history set's id-less chat. + * + * Upstream guards the history-set lookup with `id &&`, so a `chats.upsert` + * carrying no id starts its own entry. This port had dropped that guard, and + * the two folded together: their unread counts summed and the upsert was never + * released on its own. Found by the buffer differential once history rows drew + * from the same identity pool as live traffic, which is the only way the two + * ever met. + */ + it('keeps an id-less chats.upsert out of a buffered history set', () => { + const ev = makeEventBuffer(logger) + const upserts: unknown[] = [] + const sets: BaileysEventMap['messaging-history.set'][] = [] + ev.on('chats.upsert', chats => upserts.push(...chats)) + ev.on('messaging-history.set', set => sets.push(set)) + + ev.buffer() + ev.emit('messaging-history.set', { + chats: [{ id: '', conversationTimestamp: 737, unreadCount: 4 } as never], + contacts: [], + messages: [], + isLatest: false, + syncType: undefined, + progress: undefined, + peerDataRequestSessionId: undefined + } as never) + ev.emit('chats.upsert', [{ id: '', conversationTimestamp: -918, unreadCount: 5 } as never]) + ev.flush() + + // The history chat keeps its own values... + expect(sets[0]?.chats?.[0]?.unreadCount).toEqual(4) + expect(sets[0]?.chats?.[0]?.conversationTimestamp).toEqual(737) + // ...and the upsert is released separately rather than summed into it. + expect(upserts.length).toEqual(1) + }) }) diff --git a/tsconfig.build.json b/tsconfig.build.json index 488f3587..822bde4f 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -8,5 +8,11 @@ "declarationMap": true, "sourceMap": true }, - "exclude": ["node_modules", "src/**/__tests__/**", "src/**/*.test.ts", "src/**/*.test-e2e.ts"] + "exclude": [ + "node_modules", + "src/**/__tests__/**", + "src/__fuzz__/**", + "src/**/*.test.ts", + "src/**/*.test-e2e.ts" + ] }