Skip to content

Commit 85ba3dd

Browse files
committed
ci: fuzz the parser, the query modes and pg-native on a random seed
1 parent b44c445 commit 85ba3dd

8 files changed

Lines changed: 1067 additions & 1 deletion

File tree

‎.github/workflows/ci.yml‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,47 @@ jobs:
9393
cache: yarn
9494
- run: yarn install --frozen-lockfile
9595
- run: yarn test
96+
97+
# A few fuzz rounds on a seed nobody chose, every push and every pull request. One run proves
98+
# nothing; a few hundred runs walk over ground no hand written test reaches, and the seed
99+
# changes every time so the coverage accumulates across pushes. A divergence turns this job
100+
# red and prints the seed that reproduces it, with the case shrunk to the lines worth pasting
101+
# into a test: replay it locally with `node fuzz/<tool>.js --seed N --rounds 1`.
102+
fuzz:
103+
timeout-minutes: 15
104+
needs: lint
105+
services:
106+
postgres:
107+
image: ghcr.io/railwayapp-templates/postgres-ssl:18
108+
env:
109+
POSTGRES_USER: postgres
110+
POSTGRES_PASSWORD: postgres
111+
POSTGRES_HOST_AUTH_METHOD: 'md5'
112+
POSTGRES_DB: ci_db_test
113+
PGDATA: /var/lib/postgresql/data
114+
ports:
115+
- 5432:5432
116+
options: --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
117+
runs-on: ubuntu-latest
118+
env:
119+
PGUSER: postgres
120+
PGPASSWORD: postgres
121+
PGHOST: localhost
122+
PGDATABASE: ci_db_test
123+
steps:
124+
- uses: actions/checkout@v4
125+
with:
126+
persist-credentials: false
127+
- name: Setup node
128+
uses: actions/setup-node@v4
129+
with:
130+
node-version: 26
131+
cache: yarn
132+
- run: yarn install --frozen-lockfile
133+
- run: yarn build
134+
- name: Fuzz the protocol parser against the bytes it was given, cut at random points
135+
run: node fuzz/wire.js --rounds 500
136+
- name: Fuzz pg against itself in every query mode
137+
run: node fuzz/modes.js --rounds 100
138+
- name: Fuzz pg against pg-native
139+
run: node fuzz/native.js --rounds 100

‎fuzz/README.md‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# Fuzzing
2+
3+
Three differential fuzzers. Each draws random cases from a seed, runs them two ways that must
4+
agree, and on a divergence prints the seed that reproduces it and the case shrunk to the lines
5+
worth pasting into a test. The CI runs a few hundred rounds of each on a seed nobody chose, on
6+
every push and pull request.
7+
8+
| Tool | Arms | Needs a server |
9+
| --- | --- | --- |
10+
| `node fuzz/wire.js` | the pg-protocol parser on random backend messages, in one buffer and cut at random points, against what was written | no |
11+
| `node fuzz/modes.js` | `client.query` against the extended protocol forced, a named statement run twice, rowMode array, the binary result format, pipeline mode, pg-cursor and pg-query-stream | yes, `PG*` variables |
12+
| `node fuzz/native.js` | pg against pg-native, as a plain query, a named statement and rowMode array | yes, and pg-native built |
13+
14+
```bash
15+
node fuzz/modes.js --rounds 200 # longer
16+
node fuzz/modes.js --seed 12345 --rounds 1 # replay what a past run printed
17+
node fuzz/modes.js --keep-going # do not stop at the first divergence
18+
node fuzz/modes.js --no-shrink # print the round as drawn
19+
```
20+
21+
The queries come from `queries.js`: selects over `generate_series` with a column per type family,
22+
sometimes as a parameter instead of a literal, writes on a temp table, and statements that fail
23+
on purpose, some of them only after rows were sent. What the binary arm can compare is limited to
24+
the types pg-types has a binary parser for, the `binary` flag of each type says which.

‎fuzz/lib.js‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
'use strict'
2+
3+
// What the three fuzzers share: a seeded generator, so a failure prints the seed that reproduces
4+
// it; the loop over rounds; and the shrinking, which drops the parts of a failing case one at a
5+
// time for as long as the failure survives, so what gets printed is the few lines worth pasting
6+
// into a test rather than the whole round.
7+
8+
/** @param {number} seed @returns {() => number} the same sequence for the same seed */
9+
const mulberry32 = (seed) => {
10+
let a = seed >>> 0
11+
return () => {
12+
a = (a + 0x6d2b79f5) >>> 0
13+
let t = Math.imul(a ^ (a >>> 15), 1 | a)
14+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
15+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
16+
}
17+
}
18+
19+
const int = (rng, min, max) => min + Math.floor(rng() * (max - min + 1))
20+
const pick = (rng, items) => items[Math.floor(rng() * items.length)]
21+
const chance = (rng, p) => rng() < p
22+
23+
// strings a server can send: no NUL, since the protocol delimits them with one, and a spread
24+
// from empty to long with multibyte and awkward characters in between
25+
const ALPHABET = ['a', 'Z', '0', ' ', '_', "'", '"', '\\', '{', '}', ',', 'é', '€', '😀', '\n', '\t', 'ÿ']
26+
const string = (rng, max = 12) => {
27+
const length = chance(rng, 0.1) ? 0 : chance(rng, 0.05) ? int(rng, 100, max * 40) : int(rng, 1, max)
28+
let out = ''
29+
for (let i = 0; i < length; i++) out += chance(rng, 0.7) ? pick(rng, ALPHABET.slice(0, 3)) : pick(rng, ALPHABET)
30+
return out
31+
}
32+
33+
const bytes = (rng, max = 16) => {
34+
const length = chance(rng, 0.1) ? 0 : int(rng, 1, max)
35+
const out = Buffer.alloc(length)
36+
for (let i = 0; i < length; i++) out[i] = chance(rng, 0.2) ? 0 : int(rng, 0, 255)
37+
return out
38+
}
39+
40+
// one canonical text for a value, so two results compare as strings: a Buffer by its bytes, a
41+
// Date by its instant, and the rest by JSON with the keys in insertion order
42+
const canon = (value) => {
43+
const replacer = (_, v) => {
44+
if (Buffer.isBuffer(v)) return `<${v.toString('hex')}>`
45+
if (v && v.type === 'Buffer' && Array.isArray(v.data)) return `<${Buffer.from(v.data).toString('hex')}>`
46+
if (typeof v === 'bigint') return `${v}n`
47+
if (v === undefined) return '<undefined>'
48+
if (typeof v === 'number' && !Number.isFinite(v)) return `<${v}>`
49+
return v
50+
}
51+
return JSON.stringify(value instanceof Date ? value.toISOString() : value, replacer)
52+
}
53+
54+
const parseArgs = (argv) => {
55+
const flag = (name, fallback) => {
56+
const at = argv.indexOf(`--${name}`)
57+
return at === -1 ? fallback : Number(argv[at + 1])
58+
}
59+
return {
60+
rounds: flag('rounds', 100),
61+
seed: flag('seed', (Date.now() ^ (process.pid << 16)) >>> 0),
62+
keepGoing: argv.includes('--keep-going'),
63+
noShrink: argv.includes('--no-shrink'),
64+
}
65+
}
66+
67+
/**
68+
* Drops items from a failing case one at a time, keeping every drop the failure survives.
69+
*
70+
* @template T
71+
* @param {T} plan
72+
* @param {(plan: T) => T[]} variants the smaller plans to try, each with one thing removed
73+
* @param {(plan: T) => Promise<boolean>} fails
74+
*/
75+
const shrink = async (plan, variants, fails) => {
76+
let current = plan
77+
let progress = true
78+
while (progress) {
79+
progress = false
80+
for (const smaller of variants(current)) {
81+
if (await fails(smaller)) {
82+
current = smaller
83+
progress = true
84+
break
85+
}
86+
}
87+
}
88+
return current
89+
}
90+
91+
/**
92+
* The loop every fuzzer runs: draw a case per round, run it, and on a divergence print the seed,
93+
* shrink the case and print it as source.
94+
*
95+
* @param {object} fuzzer
96+
* @param {string} fuzzer.name
97+
* @param {(rng: () => number) => any} fuzzer.draw
98+
* @param {(plan: any) => Promise<string|null>} fuzzer.run a description of the divergence, or null
99+
* @param {(plan: any) => any[]} fuzzer.variants
100+
* @param {(plan: any) => string} fuzzer.source
101+
* @param {() => Promise<void>} [fuzzer.close]
102+
*/
103+
const main = async (fuzzer) => {
104+
const args = parseArgs(process.argv.slice(2))
105+
console.log(`${fuzzer.name}: ${args.rounds} rounds from seed ${args.seed}`)
106+
let found = 0
107+
for (let round = 0; round < args.rounds; round++) {
108+
const seed = (args.seed + round) >>> 0
109+
const plan = fuzzer.draw(mulberry32(seed))
110+
const divergence = await fuzzer.run(plan)
111+
if (!divergence) {
112+
if (round % 25 === 24) console.log(` ${round + 1} rounds, no divergence`)
113+
continue
114+
}
115+
found++
116+
console.log(`\n=== divergence in round ${round}, seed ${seed} (replay: --seed ${seed} --rounds 1)`)
117+
console.log(divergence)
118+
if (!args.noShrink) {
119+
console.log('\nshrinking...')
120+
const small = await shrink(plan, fuzzer.variants, async (p) => Boolean(await fuzzer.run(p)))
121+
console.log(`\n${fuzzer.source(small)}`)
122+
console.log(await fuzzer.run(small))
123+
}
124+
if (!args.keepGoing) break
125+
}
126+
if (fuzzer.close) await fuzzer.close()
127+
console.log(`\n${found} divergence${found === 1 ? '' : 's'}`)
128+
process.exit(found ? 1 : 0)
129+
}
130+
131+
module.exports = { mulberry32, int, pick, chance, string, bytes, canon, shrink, main }

‎fuzz/modes.js‎

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use strict'
2+
3+
// pg against itself: the same statements, on one connection per mode, and every mode must
4+
// answer what a plain query on a plain client answered.
5+
//
6+
// The reference arm is `client.query(text, values)`. The others are the paths a program can
7+
// take to the same rows: the extended protocol forced on a query that would have used the
8+
// simple one, a named prepared statement run twice, rowMode array, the binary result format,
9+
// pipeline mode with the whole round in flight at once, pg-cursor reading in batches and
10+
// pg-query-stream. Each keeps its own connection and runs the whole round in order, so the
11+
// temp table and the transaction state are the same on every arm.
12+
//
13+
// node fuzz/modes.js fifty rounds, PG* variables say where the server is
14+
// node fuzz/modes.js --seed 12345 replay what a past run did
15+
// node fuzz/modes.js --keep-going do not stop at the first divergence
16+
17+
const { createHash } = require('crypto')
18+
const pg = require('../packages/pg')
19+
const Cursor = require('../packages/pg-cursor')
20+
const QueryStream = require('../packages/pg-query-stream')
21+
const { int, mulberry32, main } = require('./lib')
22+
const { draw, render, summarize, QUIET, rowsOnly, source, variants, SETUP } = require('./queries')
23+
24+
// the outcome of a query, whichever way it went
25+
const outcome = (promise) =>
26+
promise.then(
27+
(result) => ({ result }),
28+
(error) => ({ error })
29+
)
30+
const settle = (promise) => outcome(promise).then(({ result, error }) => summarize(result, error))
31+
const settleRows = (promise) => outcome(promise).then(({ result, error }) => rowsOnly(result, error))
32+
33+
const name = (text) => `fuzz_${createHash('sha1').update(text).digest('hex')}`
34+
35+
// each arm runs one query of the round on its own connection and answers the canonical text.
36+
// `rng` is the round's, so a batch size is drawn the same on a replay
37+
const ARMS = {
38+
extended: (client, q) => settle(client.query({ text: q.text, values: q.values, queryMode: 'extended' })),
39+
// twice, so the second run goes through the statement cache; only a select, since running a
40+
// write twice would leave this arm's table different from the others
41+
prepared: async (client, q) => {
42+
const first = await settle(client.query({ text: q.text, values: q.values, name: name(q.text) }))
43+
// and not after a failure either, which would have aborted an open transaction
44+
if (q.kind !== 'select' || first.startsWith('{"error"')) return first
45+
const second = await settle(client.query({ text: q.text, values: q.values, name: name(q.text) }))
46+
return first === second ? first : `first run: ${first}\n second run: ${second}`
47+
},
48+
array: async (client, q, rng, reference) => {
49+
const got = await settle(client.query({ text: q.text, values: q.values, rowMode: 'array' }))
50+
// the reference rows as arrays, which is the only thing this mode changes
51+
const { result, error } = reference
52+
const expected = summarize(result && { ...result, rows: result.rows.map(Object.values) }, error)
53+
return got === expected ? summarize(result, error) : got
54+
},
55+
binary: (client, q) =>
56+
q.binary
57+
? settle(client.query({ text: q.text, values: q.values, binary: true }))
58+
: settle(client.query(q.text, q.values)),
59+
cursor: async (client, q, rng) => {
60+
if (q.kind !== 'select') return settleRows(client.query(q.text, q.values))
61+
const cursor = client.query(new Cursor(q.text, q.values))
62+
const rows = []
63+
try {
64+
for (;;) {
65+
const batch = await cursor.read(int(rng, 1, 40))
66+
if (batch.length === 0) break
67+
rows.push(...batch)
68+
}
69+
await cursor.close()
70+
return rowsOnly({ rows })
71+
} catch (error) {
72+
return rowsOnly(null, error)
73+
}
74+
},
75+
stream: async (client, q, rng) => {
76+
if (q.kind !== 'select') return settleRows(client.query(q.text, q.values))
77+
const rows = []
78+
try {
79+
const stream = client.query(
80+
new QueryStream(q.text, q.values, { batchSize: int(rng, 1, 40), highWaterMark: int(rng, 1, 40) })
81+
)
82+
for await (const row of stream) rows.push(row)
83+
return rowsOnly({ rows })
84+
} catch (error) {
85+
return rowsOnly(null, error)
86+
}
87+
},
88+
}
89+
90+
const clients = {}
91+
const connect = async () => {
92+
clients.reference = new pg.Client()
93+
clients.pipeline = new pg.Client({ pipeline: true })
94+
for (const arm of Object.keys(ARMS)) clients[arm] = new pg.Client()
95+
for (const client of Object.values(clients)) {
96+
await client.connect()
97+
await client.query(QUIET)
98+
}
99+
}
100+
101+
// every round starts from the same state on every arm: no transaction open, an empty table
102+
const reset = async (client) => {
103+
await client.query('ROLLBACK').catch(() => {})
104+
await client.query('DROP TABLE IF EXISTS fuzz_rows')
105+
await client.query(SETUP)
106+
}
107+
108+
const run = async (plan) => {
109+
if (!clients.reference) await connect()
110+
for (const client of Object.values(clients)) await reset(client)
111+
const rng = mulberry32(plan.queries.length)
112+
const queries = plan.queries.map(render)
113+
const references = []
114+
for (const q of queries) references.push(await outcome(clients.reference.query(q.text, q.values)))
115+
const full = references.map(({ result, error }) => summarize(result, error))
116+
117+
// the whole round in flight at once on the pipelined connection
118+
const pipelined = await Promise.all(queries.map((q) => settle(clients.pipeline.query(q.text, q.values))))
119+
for (let i = 0; i < queries.length; i++) {
120+
if (pipelined[i] !== full[i])
121+
return `pipeline, query ${i}
122+
reference: ${full[i]}
123+
pipeline: ${pipelined[i]}`
124+
}
125+
126+
for (const [arm, runArm] of Object.entries(ARMS)) {
127+
for (let i = 0; i < queries.length; i++) {
128+
const got = await runArm(clients[arm], queries[i], rng, references[i])
129+
const { result, error } = references[i]
130+
const expected = arm === 'cursor' || arm === 'stream' ? rowsOnly(result, error) : full[i]
131+
if (got !== expected)
132+
return `${arm}, query ${i}
133+
reference: ${expected}
134+
${arm}: ${got}`
135+
}
136+
}
137+
return null
138+
}
139+
140+
const close = async () => {
141+
for (const client of Object.values(clients)) await client.end()
142+
}
143+
144+
main({ name: 'modes', draw, run, variants, source, close })

0 commit comments

Comments
 (0)