kryptheon-night 0.1.39 → 0.1.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/attack.js +1028 -1025
- package/bin/kryptheon-night.js +736 -725
- package/engine.sql +5001 -4430
- package/finding.js +1804 -1612
- package/intro.js +311 -303
- package/package.json +3 -3
- package/races.js +304 -0
- package/recheck.js +4 -0
- package/scan.js +1336 -1311
- package/schema.js +1588 -1555
- package/tamper.js +949 -772
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "kryptheon-night",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.41",
|
|
4
4
|
"description": "Attacks a copy of your Supabase database and tells you in plain English what got in.",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"type": "commonjs",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"outside.js",
|
|
17
17
|
"collision.js",
|
|
18
18
|
"tamper.js",
|
|
19
|
-
"orphan.js",
|
|
19
|
+
"orphan.js", "races.js",
|
|
20
20
|
"recheck.js",
|
|
21
21
|
"intro.js",
|
|
22
22
|
"trouble.js",
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"node": ">=18.0.0"
|
|
32
32
|
},
|
|
33
33
|
"scripts": {
|
|
34
|
-
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node identity.check.js",
|
|
34
|
+
"check": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node outside.check.js && node recheck.check.js && node untouched.check.js && node blocked.check.js && node orphans.check.js && node guests.check.js && node usable.check.js && node twin.check.js && node engines.check.js && node external.check.js && node schema.check.js && node collision.check.js && node tamper.check.js && node orphan.check.js && node shapes.check.js && node verdicts.check.js && node shop.check.js && node privileged.check.js && node recursion.check.js && node guarded.check.js && node nightly.check.js && node words.check.js && node helpers.check.js && node deeploop.check.js && node teams.check.js && node roles.check.js && node buckets.check.js && node cli.check.js && node loop.check.js && node report.check.js && node installscript.check.js && node tenants.check.js && node takeover.check.js && node elevated.check.js && node checkfit.check.js && node declared.check.js && node races.check.js && node identity.check.js",
|
|
35
35
|
"check:dry": "node package.check.js && node store.check.js && node trouble.check.js && node intro.check.js && node finding.check.js && node recheck.check.js && node outside.check.js",
|
|
36
36
|
"check:package": "node package.check.js",
|
|
37
37
|
"check:store": "node store.check.js",
|
package/races.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
// Races, read from the app's own functions.
|
|
2
|
+
//
|
|
3
|
+
// collision.js explains why a lost update cannot be reported from the schema:
|
|
4
|
+
// whether two requests at once both win depends on code the tool never sees.
|
|
5
|
+
// On Supabase that code is very often in the database - an RPC such as
|
|
6
|
+
// reserve_stock() or create_ticket() - and its text is in the catalogue. So
|
|
7
|
+
// this reads it, and says what the text shows: a step that looks something up
|
|
8
|
+
// and then writes on the strength of it, with nothing that makes a second
|
|
9
|
+
// caller wait or fail. Nothing is run. Every finding is "to check".
|
|
10
|
+
//
|
|
11
|
+
// Three shapes, each missed by a blind test (rounds 11, 15 and 16):
|
|
12
|
+
//
|
|
13
|
+
// number SELECT max(n) + 1 INTO v FROM t, then INSERT INTO t with it - or
|
|
14
|
+
// hand it back for the app to insert - and no unique rule on n:
|
|
15
|
+
// two callers take the same number.
|
|
16
|
+
// (create_ticket, create_work_order, reserve_subscription_code)
|
|
17
|
+
// overdraw SELECT a quantity INTO a variable, RAISE when it is too low,
|
|
18
|
+
// then UPDATE it: two callers both pass the check. (reserve_stock)
|
|
19
|
+
// double look for a row (IF EXISTS, or SELECT ... INTO and an IF on it),
|
|
20
|
+
// then INSERT one, and no unique rule on what was looked up: two
|
|
21
|
+
// callers both find nothing and both insert.
|
|
22
|
+
// (assign_asset, record_receipt)
|
|
23
|
+
//
|
|
24
|
+
// A shape is safe, and only counted as examined, when:
|
|
25
|
+
// - a unique key or unique index covers the columns it depends on (the
|
|
26
|
+
// second caller then fails instead of slipping through);
|
|
27
|
+
// - the INSERT has ON CONFLICT;
|
|
28
|
+
// - for overdraw, the read is FOR UPDATE / FOR SHARE, the UPDATE tests the
|
|
29
|
+
// column again in its WHERE, or a CHECK holds it (see below);
|
|
30
|
+
// - a row lock comes earlier in the function, or it takes an advisory lock,
|
|
31
|
+
// LOCK TABLE or SERIALIZABLE anywhere.
|
|
32
|
+
//
|
|
33
|
+
// Mirrored in engine.sql race_shapes.
|
|
34
|
+
|
|
35
|
+
const LOCKS = /\b(pg_advisory_xact_lock|pg_advisory_lock|pg_try_advisory_xact_lock|pg_try_advisory_lock|lock\s+table|isolation\s+level\s+serializable)\b/;
|
|
36
|
+
const ROW_LOCK = /\bfor\s+(update|no\s+key\s+update|share|key\s+share)\b/;
|
|
37
|
+
// A name as written, quoted or not: "public"."tickets" as well as public.tickets.
|
|
38
|
+
const NAME = '"?[a-z_][a-z0-9_$."]*';
|
|
39
|
+
|
|
40
|
+
/** The body as statements: comments and string contents gone, lower case,
|
|
41
|
+
* one space, split on ";", and a leading BEGIN / END IF / ELSE dropped so a
|
|
42
|
+
* statement starts with what it does. */
|
|
43
|
+
function statementsOf(def) {
|
|
44
|
+
const text = String(def || '')
|
|
45
|
+
.replace(/\/\*[\s\S]*?\*\//g, ' ')
|
|
46
|
+
.replace(/--[^\n]*/g, ' ')
|
|
47
|
+
.replace(/'(?:[^']|'')*'/g, "''")
|
|
48
|
+
.toLowerCase()
|
|
49
|
+
.replace(/\s+/g, ' ')
|
|
50
|
+
// Only the body: "create function ... as $function$" and the closing
|
|
51
|
+
// $function$ off. Left on, a function with no DECLARE had its first IF
|
|
52
|
+
// glued to the header and never read as an IF.
|
|
53
|
+
.replace(/^[\s\S]*?\bas\s+\$[a-z0-9_]*\$\s*/, '')
|
|
54
|
+
.replace(/\s*\$[a-z0-9_]*\$\s*$/, '');
|
|
55
|
+
return text.split(';')
|
|
56
|
+
.map((x) => x.trim().replace(/^(?:(?:begin|else|then|loop|end\s+if|end\s+loop)\s+)+/, ''))
|
|
57
|
+
.filter(Boolean);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A name as written - public.t, "t", x.col - reduced to its last part. */
|
|
61
|
+
function bare(name) {
|
|
62
|
+
const parts = String(name || '').replace(/"/g, '').split('.');
|
|
63
|
+
return parts[parts.length - 1];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Identifiers in a piece of SQL, each reduced to its last part. */
|
|
67
|
+
function identifiers(text) {
|
|
68
|
+
return (String(text || '').match(/[a-z_][a-z0-9_$."]*/g) || []).map(bare);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mentions(text, names) {
|
|
72
|
+
return names.some((v) => new RegExp('(^|[^a-z0-9_$])' + v.replace(/\$/g, '\\$') + '($|[^a-z0-9_$])').test(text));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The text up to the bracket that closes the one already open. */
|
|
76
|
+
function untilClose(text) {
|
|
77
|
+
let depth = 0;
|
|
78
|
+
for (let i = 0; i < text.length; i++) {
|
|
79
|
+
if (text[i] === '(') depth++;
|
|
80
|
+
else if (text[i] === ')') { if (depth === 0) return text.slice(0, i); depth--; }
|
|
81
|
+
}
|
|
82
|
+
return text;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Column sets a second caller cannot repeat: primary keys, uniques, unique indexes. */
|
|
86
|
+
function uniqueSetsOf(plan, table) {
|
|
87
|
+
const sets = [];
|
|
88
|
+
const t = (plan.tables || []).find((x) => x.name === table);
|
|
89
|
+
for (const con of (t && t.constraints) || []) {
|
|
90
|
+
if (con.kind !== 'u' && con.kind !== 'p') continue;
|
|
91
|
+
const inner = /\(([^)]*)\)/.exec(con.definition || '');
|
|
92
|
+
if (inner) sets.push(identifiers(inner[1].toLowerCase()));
|
|
93
|
+
}
|
|
94
|
+
for (const idx of plan.indexes || []) {
|
|
95
|
+
if (idx.table_name !== table) continue;
|
|
96
|
+
// CREATE UNIQUE INDEX i ON public.t USING btree (a, lower(b)) WHERE (...)
|
|
97
|
+
const at = /\busing\s+\w+\s*\(/i.exec(idx.definition || '');
|
|
98
|
+
if (at) sets.push(identifiers(untilClose(String(idx.definition).slice(at.index + at[0].length).toLowerCase())));
|
|
99
|
+
}
|
|
100
|
+
return sets.filter((x) => x.length);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function tableOf(plan, table) {
|
|
104
|
+
return (plan.tables || []).find((x) => x.name === table) || { columns: [], constraints: [] };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** FROM t [alias] WHERE ..., for the first table named after a point. The
|
|
108
|
+
* alias is never a keyword: reading "FROM t WHERE" as t aliased "where" lost
|
|
109
|
+
* the condition (assign_asset, record_receipt on round 16). */
|
|
110
|
+
function fromWhere(stmt) {
|
|
111
|
+
const m = new RegExp('\\bfrom\\s+(' + NAME + ')(?:\\s+(?:as\\s+)?(?!(?:where|for|limit|order|group|join|left|right|inner|cross|into|on|returning|union|then)\\b)[a-z_][a-z0-9_]*)?(?:\\s+where\\s+(.*))?').exec(stmt);
|
|
112
|
+
if (!m) return null;
|
|
113
|
+
const where = untilClose((m[2] || '').replace(/\b(for\s+(update|no key update|share|key share)|limit|order\s+by|group\s+by|returning)\b.*$/, ''));
|
|
114
|
+
return { table: bare(m[1]), where: where };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The columns a WHERE pins with "col = something". */
|
|
118
|
+
function pinnedBy(where) {
|
|
119
|
+
const pins = [];
|
|
120
|
+
const re = new RegExp('(' + NAME + ')\\s*=(?!=)', 'g');
|
|
121
|
+
for (let m; (m = re.exec(where));) pins.push(bare(m[1]));
|
|
122
|
+
return Array.from(new Set(pins)).sort();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function insertInto(stmts, from, table) {
|
|
126
|
+
for (let j = from; j < stmts.length; j++) {
|
|
127
|
+
const m = new RegExp('\\binsert\\s+into\\s+(' + NAME + ')').exec(stmts[j]);
|
|
128
|
+
// Only an ON CONFLICT that names its target: Postgres then needs a unique
|
|
129
|
+
// index to plan it, so the second caller is caught. A bare ON CONFLICT DO
|
|
130
|
+
// NOTHING on a table with no unique rule never conflicts at all.
|
|
131
|
+
if (m && bare(m[1]) === table) return { at: j, onConflict: /\bon\s+conflict\s*(\(|on\s+constraint\b)/.test(stmts[j]) };
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** SET a = <expr>, b = <expr>: each target with what it is set to. */
|
|
137
|
+
function setsOf(list) {
|
|
138
|
+
const out = [];
|
|
139
|
+
let depth = 0, cut = 0;
|
|
140
|
+
for (let k = 0; k <= list.length; k++) {
|
|
141
|
+
const ch = list[k];
|
|
142
|
+
if (ch === '(') depth++;
|
|
143
|
+
else if (ch === ')') depth--;
|
|
144
|
+
else if ((ch === ',' && depth === 0) || k === list.length) {
|
|
145
|
+
const m = new RegExp('^\\s*(' + NAME + ')\\s*=(.*)$').exec(list.slice(cut, k));
|
|
146
|
+
if (m) out.push({ column: bare(m[1]), expr: m[2] });
|
|
147
|
+
cut = k + 1;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return out;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** SELECT <list> INTO <vars> FROM ...: split at the first " into " and the
|
|
154
|
+
* first " from " after it - by position, not a lazy pattern, so engine.sql
|
|
155
|
+
* (whose regular expressions are not lazy the same way) reads it alike. */
|
|
156
|
+
function selectInto(stmt) {
|
|
157
|
+
if (!stmt.startsWith('select ')) return null;
|
|
158
|
+
const rest = stmt.slice(7);
|
|
159
|
+
const a = rest.indexOf(' into ');
|
|
160
|
+
if (a < 0) return null;
|
|
161
|
+
let after = rest.slice(a + 6);
|
|
162
|
+
if (after.startsWith('strict ')) after = after.slice(7);
|
|
163
|
+
const b = after.indexOf(' from ');
|
|
164
|
+
if (b < 0) return null;
|
|
165
|
+
return { list: rest.slice(0, a), vars: after.slice(0, b) };
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** UPDATE t SET <list> [WHERE <cond>], split at the first " where ". */
|
|
169
|
+
function updateOf(stmt) {
|
|
170
|
+
const m = new RegExp('^update\\s+(?:only\\s+)?(' + NAME + ')\\s+(?:as\\s+[a-z_][a-z0-9_]*\\s+)?set\\s+(.*)$').exec(stmt);
|
|
171
|
+
if (!m) return null;
|
|
172
|
+
const w = m[2].indexOf(' where ');
|
|
173
|
+
return { table: bare(m[1]), list: w < 0 ? m[2] : m[2].slice(0, w), where: w < 0 ? '' : m[2].slice(w + 7) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Every race shape one function's text shows, each marked safe or not. */
|
|
177
|
+
function shapesInFunction(fn, plan) {
|
|
178
|
+
const out = [];
|
|
179
|
+
const stmts = statementsOf(fn.def);
|
|
180
|
+
const locked = LOCKS.test(stmts.join('; '));
|
|
181
|
+
const tables = new Set((plan.tables || []).map((t) => t.name));
|
|
182
|
+
const lockedBy = (i) => stmts.slice(0, i + 1).some((x) => ROW_LOCK.test(x));
|
|
183
|
+
const add = (how, table, column, safe) => out.push({ how: how, table: table, column: column, safe: locked || safe });
|
|
184
|
+
|
|
185
|
+
stmts.forEach((s, i) => {
|
|
186
|
+
// number
|
|
187
|
+
const max = /\bmax\s*\(/.exec(s);
|
|
188
|
+
if (max && /\)\s*\+\s*1\b/.test(s.slice(max.index)) && /\binto\b/.test(s)) {
|
|
189
|
+
const src = fromWhere(s.slice(max.index));
|
|
190
|
+
if (!src || !tables.has(src.table)) return;
|
|
191
|
+
const cols = new Set(tableOf(plan, src.table).columns.map((c) => String(c.name).toLowerCase()));
|
|
192
|
+
const column = identifiers(untilClose(s.slice(max.index + max[0].length))).find((c) => cols.has(c));
|
|
193
|
+
if (!column) return;
|
|
194
|
+
const ins = insertInto(stmts, i + 1, src.table);
|
|
195
|
+
// Or handed back for the app to insert: reserve_subscription_code()
|
|
196
|
+
// returned "PREFIX-00042" built from it (round 11, HL-04), through any
|
|
197
|
+
// x := ... n ... on the way.
|
|
198
|
+
const into = /\binto\s+([a-z_][a-z0-9_$]*)\s+from\b/.exec(s);
|
|
199
|
+
const carried = into ? [into[1]] : [];
|
|
200
|
+
let handedBack = false;
|
|
201
|
+
for (const x of stmts.slice(i + 1)) {
|
|
202
|
+
const set = /^([a-z_][a-z0-9_$]*)\s*:=/.exec(x);
|
|
203
|
+
if (set && mentions(x, carried)) carried.push(set[1]);
|
|
204
|
+
else if (/^return\b/.test(x) && mentions(x, carried)) { handedBack = true; break; }
|
|
205
|
+
}
|
|
206
|
+
if (!ins && !handedBack) return;
|
|
207
|
+
add('number', src.table, column,
|
|
208
|
+
lockedBy(i) || (ins && ins.onConflict) || uniqueSetsOf(plan, src.table).some((set) => set.includes(column)));
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const read = selectInto(s);
|
|
213
|
+
|
|
214
|
+
// overdraw
|
|
215
|
+
if (read) {
|
|
216
|
+
const src = fromWhere(s);
|
|
217
|
+
if (src && tables.has(src.table)) {
|
|
218
|
+
const selected = identifiers(read.list);
|
|
219
|
+
const vars = identifiers(read.vars);
|
|
220
|
+
const tab = tableOf(plan, src.table);
|
|
221
|
+
const cols = new Set(tab.columns.map((c) => String(c.name).toLowerCase()));
|
|
222
|
+
const checks = tab.constraints.filter((c) => c.kind === 'c').map((c) => String(c.definition || '').toLowerCase());
|
|
223
|
+
for (let j = i + 1; j < stmts.length; j++) {
|
|
224
|
+
const up = updateOf(stmts[j]);
|
|
225
|
+
if (!up || up.table !== src.table) continue;
|
|
226
|
+
const refused = stmts.slice(i + 1, j).some((x) => /\braise\b/.test(x) && mentions(x, vars));
|
|
227
|
+
if (!refused) continue;
|
|
228
|
+
for (const { column, expr } of setsOf(up.list)) {
|
|
229
|
+
if (!selected.includes(column)) continue;
|
|
230
|
+
// A CHECK stops the second caller only when the UPDATE works on
|
|
231
|
+
// the column itself (a = a - x) and the CHECK is a floor under the
|
|
232
|
+
// subtraction, or ties the column to another one (reserved <=
|
|
233
|
+
// on_hand). "a = v - x", set from what was read, is the lost
|
|
234
|
+
// update itself and no CHECK sees it; reserve_stock's
|
|
235
|
+
// CHECK (reserved_quantity >= 0) does nothing for an addition.
|
|
236
|
+
const self = mentions(expr, [column]);
|
|
237
|
+
const held = self && checks.some((c) => mentions(c, [column]) &&
|
|
238
|
+
(/-/.test(expr) || identifiers(c).some((x) => x !== column && cols.has(x))));
|
|
239
|
+
add('overdraw', src.table, column, lockedBy(i) || mentions(up.where, [column]) || held);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// double
|
|
246
|
+
const exists = /\bexists\s*\(\s*select\b/.exec(s);
|
|
247
|
+
const lookup = exists ? fromWhere(s.slice(exists.index))
|
|
248
|
+
: read && !/\b(count|sum|min|avg|max)\s*\(/.test(read.list) ? fromWhere(s) : null;
|
|
249
|
+
if (!lookup || !tables.has(lookup.table)) return;
|
|
250
|
+
const pins = pinnedBy(lookup.where);
|
|
251
|
+
const ins = insertInto(stmts, i + 1, lookup.table);
|
|
252
|
+
if (!pins.length || !ins) return;
|
|
253
|
+
const vars = read && !exists ? identifiers(read.vars) : [];
|
|
254
|
+
const branches = exists ? /^(if|elsif)\b/.test(s)
|
|
255
|
+
: stmts.slice(i + 1, ins.at).some((x) => /^(if|elsif)\b/.test(x) && (/\bfound\b/.test(x) || mentions(x, vars)));
|
|
256
|
+
if (!branches) return;
|
|
257
|
+
add('double', lookup.table, pins.join(','),
|
|
258
|
+
lockedBy(i) || ins.onConflict || uniqueSetsOf(plan, lookup.table).some((set) => set.every((c) => pins.includes(c))));
|
|
259
|
+
});
|
|
260
|
+
return out;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Every shape in the app's functions: { fn, args, how, table, column, safe }. */
|
|
264
|
+
function raceShapesOf(plan) {
|
|
265
|
+
const out = [];
|
|
266
|
+
for (const fn of (plan && plan.functions) || []) {
|
|
267
|
+
if (fn.from_extension) continue;
|
|
268
|
+
if (fn.language && !/^(plpgsql|sql)$/.test(String(fn.language))) continue;
|
|
269
|
+
for (const shape of shapesInFunction(fn, plan)) out.push(Object.assign({ fn: fn.name, args: fn.args || '' }, shape));
|
|
270
|
+
}
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const order = (a, b) => {
|
|
275
|
+
const x = a.fn + '\u0000' + a.how + '\u0000' + a.table + '\u0000' + a.column;
|
|
276
|
+
const y = b.fn + '\u0000' + b.how + '\u0000' + b.table + '\u0000' + b.column;
|
|
277
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
/** The races to report: shapes nothing makes safe, one per function, how,
|
|
281
|
+
* table and column. */
|
|
282
|
+
function racesOf(plan) {
|
|
283
|
+
const seen = new Map();
|
|
284
|
+
for (const shape of raceShapesOf(plan)) {
|
|
285
|
+
const key = shape.fn + ':' + shape.how + ':' + shape.table + ':' + shape.column;
|
|
286
|
+
seen.set(key, seen.has(key) ? (seen.get(key) && shape.safe) : shape.safe);
|
|
287
|
+
}
|
|
288
|
+
const out = [];
|
|
289
|
+
for (const shape of raceShapesOf(plan)) {
|
|
290
|
+
const key = shape.fn + ':' + shape.how + ':' + shape.table + ':' + shape.column;
|
|
291
|
+
if (seen.get(key) !== false) continue;
|
|
292
|
+
seen.set(key, true);
|
|
293
|
+
out.push({ kind: 'race', how: shape.how, fn: shape.fn, args: shape.args, table: shape.table, column: shape.column, columns: [] });
|
|
294
|
+
}
|
|
295
|
+
return out.sort(order);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** What counts as examined, for the re-check: every shape read, safe or not,
|
|
299
|
+
* keyed the way a finding is (recheck.js keyOf). */
|
|
300
|
+
function raceKeysOf(plan) {
|
|
301
|
+
return Array.from(new Set(raceShapesOf(plan).map((x) => 'race:' + x.table + ':' + x.column))).sort();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
module.exports = { racesOf: racesOf, raceKeysOf: raceKeysOf, raceShapesOf: raceShapesOf, statementsOf: statementsOf };
|
package/recheck.js
CHANGED
|
@@ -233,6 +233,7 @@ function whatOf(item) {
|
|
|
233
233
|
if (item.kind === 'conflict') return 'an upsert Postgres refuses';
|
|
234
234
|
if (item.kind === 'nullcheck') return 'a ' + (item.column || 'value') + ' that can be left empty';
|
|
235
235
|
if (item.kind === 'noinsert') return 'no row can be added';
|
|
236
|
+
if (item.kind === 'race') return 'two calls at once in ' + (item.fn || 'a function');
|
|
236
237
|
return item.kind;
|
|
237
238
|
}
|
|
238
239
|
|
|
@@ -318,6 +319,9 @@ function describe(result) {
|
|
|
318
319
|
: 'I asked Postgres to plan it again, and it accepted it.')
|
|
319
320
|
: item.kind === 'noinsert'
|
|
320
321
|
? (elsewhere ? 'I looked here, and a rule for adding rows checks something.' : 'I looked again, and a rule for adding rows now checks something.')
|
|
322
|
+
: item.kind === 'race'
|
|
323
|
+
? (elsewhere ? 'I read it here, and something stops a second call - a unique rule, a lock, or the check repeated in the same statement.'
|
|
324
|
+
: 'I read it again, and something now stops a second call - a unique rule, a lock, or the check repeated in the same statement.')
|
|
321
325
|
: item.kind === 'nullcheck'
|
|
322
326
|
? (elsewhere ? 'I looked here, and it cannot be left empty.' : 'I looked again, and it can no longer be left empty.')
|
|
323
327
|
: item.kind === 'recursive'
|