@ultimat3/testing 30.6.0 → 30.7.0
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/CLAUDE.md +3 -3
- package/package.json +17 -17
- package/src/define-exercise.ts +19 -7
- package/src/errors.ts +6 -6
- package/src/exercise-actor-user.ts +63 -0
- package/src/exercise-actor.ts +36 -7
- package/src/exercise-crash.ts +7 -3
- package/src/exercise-drift-hint.ts +28 -0
- package/src/exercise-input.ts +3 -0
- package/src/exercise-job-tenant.ts +10 -5
- package/src/exercise-override-input.ts +26 -5
- package/src/exercise-report.ts +38 -2
- package/src/exercise-sweeps.ts +49 -7
- package/src/exercise.ts +41 -11
- package/src/index.ts +4 -0
- package/src/job-crash-points.ts +36 -0
- package/src/job-crash-proof.ts +23 -6
- package/src/job-sim-errors.ts +24 -0
- package/src/plan-conditions.ts +106 -0
- package/src/plan-oracle.ts +65 -49
- package/src/plan-sort-key.ts +65 -0
- package/src/plan-sweep-fix.ts +87 -21
- package/src/plan-sweep-report.ts +48 -4
- package/src/seed-graph-values.ts +20 -11
- package/src/seed-graph.ts +30 -1
- package/src/tenant-proof-report.ts +12 -3
- package/src/tenant-proof.ts +32 -4
- package/src/seed-pattern-sample.ts +0 -216
package/src/plan-sweep-fix.ts
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// lookup that pins a unique key reads one row whatever the planner walked to find it.
|
|
4
4
|
|
|
5
5
|
import type { PlanVerdict, PlanVerdictCode } from './plan-oracle';
|
|
6
|
+
import type { PlanUnservable } from './plan-sort-key';
|
|
7
|
+
import { sortKeyPart } from './plan-sort-key';
|
|
6
8
|
import type { SeedEntity } from './seed-graph-shape';
|
|
7
9
|
|
|
8
10
|
export interface PlanFinding {
|
|
@@ -15,6 +17,12 @@ export interface PlanFinding {
|
|
|
15
17
|
readonly table: string;
|
|
16
18
|
/** The `indexes: [{ on }]` entry, by property: tenant, equality, order, range. */
|
|
17
19
|
readonly index: readonly string[];
|
|
20
|
+
/**
|
|
21
|
+
* Why no index is the fix: a sort no declarable index serves, or `declared` — the index the node
|
|
22
|
+
* asks for is one the entity already holds, so declaring it again changes no plan. `index` is
|
|
23
|
+
* empty with it.
|
|
24
|
+
*/
|
|
25
|
+
readonly unserved?: PlanUnservable | 'declared';
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
/** Every column set the table holds unique: the primary key, unique columns, total unique rules. */
|
|
@@ -110,24 +118,74 @@ function servedByPartial(entity: SeedEntity, verdict: PlanVerdict, order: readon
|
|
|
110
118
|
|
|
111
119
|
const ORDER_BY = /\border by\s+(.+?)(?:\s+limit\b|\s+offset\b|\s+for\b|$)/gi;
|
|
112
120
|
|
|
121
|
+
/** `a, f(b, c), d` → its three items: commas outside parentheses only. */
|
|
122
|
+
function items(list: string): string[] {
|
|
123
|
+
const found: string[] = [];
|
|
124
|
+
let depth = 0;
|
|
125
|
+
let start = 0;
|
|
126
|
+
for (let at = 0; at < list.length; at += 1) {
|
|
127
|
+
if (list[at] === '(') depth += 1;
|
|
128
|
+
else if (list[at] === ')') depth -= 1;
|
|
129
|
+
else if (list[at] === ',' && depth === 0) {
|
|
130
|
+
found.push(list.slice(start, at));
|
|
131
|
+
start = at + 1;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...found, list.slice(start)];
|
|
135
|
+
}
|
|
136
|
+
|
|
113
137
|
/**
|
|
114
|
-
* The outermost `order by`'s columns, read off the text: an index walk
|
|
115
|
-
* sort key of its own, yet the index that ends the walk has to hold
|
|
138
|
+
* The outermost `order by`'s leading columns of the walked table, read off the text: an index walk
|
|
139
|
+
* avoids a sort and carries no sort key of its own, yet the index that ends the walk has to hold
|
|
140
|
+
* the order too. Only as far as one index can: plain columns of this table, one direction — past
|
|
141
|
+
* them the rows arrive presorted and Postgres finishes with an incremental sort.
|
|
116
142
|
*/
|
|
117
|
-
function orderOf(statement: string, columns: ReadonlySet<string
|
|
143
|
+
function orderOf(statement: string, columns: ReadonlySet<string>, verdict: PlanVerdict) {
|
|
118
144
|
const clauses = [...statement.matchAll(ORDER_BY)];
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
145
|
+
const order: string[] = [];
|
|
146
|
+
let way: string | undefined;
|
|
147
|
+
for (const item of items(clauses[clauses.length - 1]?.[1] ?? '')) {
|
|
148
|
+
const part = sortKeyPart(item);
|
|
149
|
+
const own =
|
|
150
|
+
part.column !== undefined &&
|
|
151
|
+
columns.has(part.column.name) &&
|
|
152
|
+
[undefined, verdict.table, verdict.alias].includes(part.column.qualifier);
|
|
153
|
+
if (!own || part.way === 'mixed' || (way !== undefined && part.way !== way)) break;
|
|
154
|
+
way = part.way;
|
|
155
|
+
if (part.column !== undefined) order.push(part.column.name);
|
|
156
|
+
}
|
|
157
|
+
return order;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* An OR the read is bounded by and nothing else: one index per arm answers it, as a BitmapOr. An
|
|
162
|
+
* arm whose column leads a declared index is answered already — the sweep's settings discourage
|
|
163
|
+
* the BitmapOr, so the plan alone cannot say; credited, never re-proven.
|
|
164
|
+
*/
|
|
165
|
+
function armFindings(
|
|
166
|
+
verdict: PlanVerdict,
|
|
167
|
+
declared: readonly (readonly string[])[],
|
|
168
|
+
property: (column: string) => string | undefined,
|
|
169
|
+
): readonly (readonly string[])[] {
|
|
170
|
+
const arms = new Map((verdict.arms ?? []).map((arm) => [arm.join('\u0000'), arm]));
|
|
171
|
+
return [...arms.values()]
|
|
172
|
+
.filter((arm) => !declared.some((key) => key[0] !== undefined && arm.includes(key[0])))
|
|
173
|
+
.map((arm) => arm.flatMap((column) => property(column) ?? []));
|
|
124
174
|
}
|
|
125
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The plan was taken with every declared index in place: one that leads with exactly these
|
|
178
|
+
* columns is already there, and printing it as the fix would change nothing.
|
|
179
|
+
*/
|
|
180
|
+
const held = (keys: readonly (readonly string[])[], columns: readonly string[]): boolean =>
|
|
181
|
+
columns.length > 0 && keys.some((key) => columns.every((column, at) => key[at] === column));
|
|
182
|
+
|
|
126
183
|
/**
|
|
127
184
|
* The findings one statement's verdicts make. A verdict whose equality columns pin a unique key is
|
|
128
185
|
* dropped: one row at most. A table's sort verdict makes its scan verdict redundant — the index it
|
|
129
186
|
* names leads with the same equality columns. The tenant column leads the index only when the
|
|
130
|
-
* statement compares it: a read across tenants is served by no index that leads with one.
|
|
187
|
+
* statement compares it: a read across tenants is served by no index that leads with one. A sort
|
|
188
|
+
* no declarable index serves, or an index the entity already holds, is red with no index to paste.
|
|
131
189
|
*/
|
|
132
190
|
export function planFindings(
|
|
133
191
|
statement: string,
|
|
@@ -145,31 +203,39 @@ export function planFindings(
|
|
|
145
203
|
key.every((column) => verdict.equality.includes(column)),
|
|
146
204
|
);
|
|
147
205
|
if (pinned) return [];
|
|
206
|
+
const finding = {
|
|
207
|
+
code: verdict.code,
|
|
208
|
+
statement,
|
|
209
|
+
node: verdict.node,
|
|
210
|
+
entity: entity.$name,
|
|
211
|
+
table: verdict.table,
|
|
212
|
+
};
|
|
213
|
+
if (verdict.unservable !== undefined) {
|
|
214
|
+
return [{ ...finding, index: [], unserved: verdict.unservable }];
|
|
215
|
+
}
|
|
148
216
|
const description = entity.$describe();
|
|
149
217
|
const byColumn = new Map(description.columns.map((c) => [c.column, c.property]));
|
|
150
218
|
const walked = verdict.code === 'X_QUERY_UNINDEXED' && verdict.node !== 'Seq Scan';
|
|
151
219
|
// A walk filtered by a range already has its order: what it lacks is the bound. A btree serves
|
|
152
220
|
// no order after a range column, so the index is the equality columns, then the range.
|
|
153
221
|
const ranged = walked && verdict.range.length > 0;
|
|
154
|
-
|
|
222
|
+
const declared = declaredIndexes(entity, statement);
|
|
223
|
+
const bounded = verdict.equality.length > 0 || verdict.range.length > 0;
|
|
224
|
+
if (!bounded && verdict.arms !== undefined && verdict.code === 'X_QUERY_UNINDEXED') {
|
|
225
|
+
const arms = armFindings(verdict, declared, (column) => byColumn.get(column));
|
|
226
|
+
return arms.map((index) => ({ ...finding, index }));
|
|
227
|
+
}
|
|
228
|
+
if (ranged && servedByRange(declared, verdict)) return [];
|
|
155
229
|
const order = ranged
|
|
156
230
|
? []
|
|
157
231
|
: walked
|
|
158
|
-
? orderOf(statement, new Set(description.columns.map((c) => c.column)))
|
|
232
|
+
? orderOf(statement, new Set(description.columns.map((c) => c.column)), verdict)
|
|
159
233
|
: verdict.order;
|
|
160
234
|
if (servedByPartial(entity, verdict, order)) return [];
|
|
161
235
|
const tenant = description.columns.find((c) => c.property === entity.$tenantColumn)?.column;
|
|
162
236
|
const lead = tenant !== undefined && verdict.equality.includes(tenant) ? [tenant] : [];
|
|
163
237
|
const columns = [...new Set([...lead, ...verdict.equality, ...order, ...verdict.range])];
|
|
164
|
-
return [
|
|
165
|
-
|
|
166
|
-
code: verdict.code,
|
|
167
|
-
statement,
|
|
168
|
-
node: verdict.node,
|
|
169
|
-
entity: entity.$name,
|
|
170
|
-
table: verdict.table,
|
|
171
|
-
index: columns.flatMap((column) => byColumn.get(column) ?? []),
|
|
172
|
-
},
|
|
173
|
-
];
|
|
238
|
+
if (held(declared, columns)) return [{ ...finding, index: [], unserved: 'declared' }];
|
|
239
|
+
return [{ ...finding, index: columns.flatMap((column) => byColumn.get(column) ?? []) }];
|
|
174
240
|
});
|
|
175
241
|
}
|
package/src/plan-sweep-report.ts
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import { renderCauseValue, renderFixLiteral, renderFixShellArg } from '@ultimat3/core';
|
|
6
6
|
import type { ExerciseRecord } from './exercise-report';
|
|
7
|
+
import type { PlanUnservable } from './plan-sort-key';
|
|
7
8
|
import type { PlanFinding } from './plan-sweep-fix';
|
|
8
9
|
|
|
9
10
|
export interface SweepFinding {
|
|
@@ -41,7 +42,47 @@ function wholeRead(finding: PlanFinding, senders: readonly ExerciseRecord[]): Sw
|
|
|
41
42
|
};
|
|
42
43
|
}
|
|
43
44
|
|
|
45
|
+
/** Why no index serves a sort, as the cause says it. */
|
|
46
|
+
const UNSERVED_SORT: Readonly<Record<PlanUnservable, string>> = {
|
|
47
|
+
expression: 'an expression over its columns, and entity() declares no expression index',
|
|
48
|
+
aggregate: 'a value an aggregate or window computes, which no stored order holds',
|
|
49
|
+
derived: 'a value a subquery or CTE derives, which no index on a table holds',
|
|
50
|
+
tables: 'columns of more than one table, and one index holds one table',
|
|
51
|
+
directions: 'columns in different directions, and an index declares one order for all of them',
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A red node no `indexes:` entry ends: a sort no declarable index holds, or one whose index the
|
|
56
|
+
* entity already declares. Printing an index would change no plan, so the fix is the rewrite that
|
|
57
|
+
* lets one apply, or the stated reason the rows stay few.
|
|
58
|
+
*/
|
|
59
|
+
function unservedRead(
|
|
60
|
+
finding: PlanFinding,
|
|
61
|
+
unserved: PlanUnservable | 'declared',
|
|
62
|
+
senders: readonly ExerciseRecord[],
|
|
63
|
+
): SweepFinding {
|
|
64
|
+
const by = senders.map(primitive).join(', ');
|
|
65
|
+
const entity = renderFixLiteral(finding.entity, '<entity>');
|
|
66
|
+
const rerun = renderFixShellArg(senders[0]?.name ?? '', '<primitive>');
|
|
67
|
+
const sent = `${by} sent ${renderCauseValue(excerpt(finding.statement))}`;
|
|
68
|
+
const cause =
|
|
69
|
+
unserved === 'declared'
|
|
70
|
+
? `${sent}, which reads ${finding.table} unbounded (${finding.node}) though entity(${entity}) already declares the index that would bound it: declaring it again changes no plan, the statement as written cannot use it`
|
|
71
|
+
: `${sent}, which sorts its rows to answer a limited read (${finding.node} under Limit) by ${UNSERVED_SORT[unserved]}: no index on ${finding.table} serves that sort, so it slows with every row the table gains`;
|
|
72
|
+
const rewrite =
|
|
73
|
+
unserved === 'declared'
|
|
74
|
+
? "the read compares the index's leading column by = or a range, no function around it"
|
|
75
|
+
: `an order by of plain ${finding.table} columns, all one direction (the rerun names their index)`;
|
|
76
|
+
return {
|
|
77
|
+
code: finding.code,
|
|
78
|
+
cause,
|
|
79
|
+
fix: `x exercise ${rerun} --json # after ${rewrite}, or expectedSeqScan('<why the rows stay few>', fn) around the read`,
|
|
80
|
+
at: senders[0] === undefined ? finding.entity : primitive(senders[0]),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
44
84
|
function planFinding(finding: PlanFinding, senders: readonly ExerciseRecord[]): SweepFinding {
|
|
85
|
+
if (finding.unserved !== undefined) return unservedRead(finding, finding.unserved, senders);
|
|
45
86
|
if (finding.index.length === 0) return wholeRead(finding, senders);
|
|
46
87
|
const by = senders.map(primitive).join(', ');
|
|
47
88
|
const entity = renderFixLiteral(finding.entity, '<entity>');
|
|
@@ -67,7 +108,8 @@ export function planSweepFindings(records: readonly ExerciseRecord[]): readonly
|
|
|
67
108
|
const groups = new Map<string, { finding: PlanFinding; senders: ExerciseRecord[] }>();
|
|
68
109
|
for (const record of records) {
|
|
69
110
|
for (const finding of record.plans?.findings ?? []) {
|
|
70
|
-
|
|
111
|
+
// An OR's arms are one finding each, on one statement: the index is part of what is red.
|
|
112
|
+
const key = [finding.code, finding.table, finding.statement, ...finding.index].join('\u0000');
|
|
71
113
|
const group = groups.get(key) ?? { finding, senders: [] };
|
|
72
114
|
group.senders.push(record);
|
|
73
115
|
groups.set(key, group);
|
|
@@ -91,9 +133,11 @@ export function planSweepFindings(records: readonly ExerciseRecord[]): readonly
|
|
|
91
133
|
export function planSweepLines(records: readonly ExerciseRecord[]): readonly string[] {
|
|
92
134
|
return records.flatMap((record) => [
|
|
93
135
|
...(record.plans?.findings ?? []).map((finding) =>
|
|
94
|
-
finding.
|
|
95
|
-
? `${primitive(record)} · ${finding.code} ${finding.table},
|
|
96
|
-
:
|
|
136
|
+
finding.unserved !== undefined
|
|
137
|
+
? `${primitive(record)} · ${finding.code} ${finding.table}, no index serves it (${finding.unserved})`
|
|
138
|
+
: finding.index.length === 0
|
|
139
|
+
? `${primitive(record)} · ${finding.code} ${finding.table}, every row (no column to index)`
|
|
140
|
+
: `${primitive(record)} · ${finding.code} ${finding.table} on [${finding.index.join(', ')}]`,
|
|
97
141
|
),
|
|
98
142
|
...(record.scale?.verdict === 'grows' ? record.scale.shapes : []).map(
|
|
99
143
|
(shape) =>
|
package/src/seed-graph-values.ts
CHANGED
|
@@ -6,7 +6,6 @@
|
|
|
6
6
|
import { fnv1a } from '@ultimat3/core';
|
|
7
7
|
import type { ColumnMeta } from '@ultimat3/entity';
|
|
8
8
|
import type * as Sample from '@ultimat3/schema/sample';
|
|
9
|
-
import { patternSamples } from './seed-pattern-sample';
|
|
10
9
|
|
|
11
10
|
/** A column as `entity()` declares it, structurally: this barrel never loads `@ultimat3/entity`. */
|
|
12
11
|
export interface SeedColumn {
|
|
@@ -72,19 +71,11 @@ const textCandidates = (property: string, meta: ColumnMeta, ctx: ValueContext):
|
|
|
72
71
|
return [...sized, ...fromPatterns(property, ctx)];
|
|
73
72
|
};
|
|
74
73
|
|
|
75
|
-
/** Strings drawn from each declared pattern, kept only where every other pattern agrees. */
|
|
76
|
-
const fromPatterns = (property: string, ctx: ValueContext): string[] => {
|
|
77
|
-
const patterns = ctx.patterns ?? [];
|
|
78
|
-
return patterns
|
|
79
|
-
.flatMap((pattern) => patternSamples(pattern, `${property}:${ctx.canary}`))
|
|
80
|
-
.filter((sample) => patterns.every((pattern) => pattern.test(sample)));
|
|
81
|
-
};
|
|
82
|
-
|
|
83
74
|
let loadedSampler: typeof Sample | undefined;
|
|
84
75
|
|
|
85
76
|
/**
|
|
86
|
-
* `@ultimat3/schema/sample`, loaded on the first `json(schema)` column
|
|
87
|
-
* module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
|
|
77
|
+
* `@ultimat3/schema/sample`, loaded on the first pattern or `json(schema)` column, never at import:
|
|
78
|
+
* this module is on the barrel, and the walker is a dynamic-only dependency there (`CLAUDE.md`).
|
|
88
79
|
* `require` and not `import()` because every caller of `candidatesFor` is synchronous.
|
|
89
80
|
*/
|
|
90
81
|
const sampler = (): typeof Sample => {
|
|
@@ -92,6 +83,24 @@ const sampler = (): typeof Sample => {
|
|
|
92
83
|
return loadedSampler;
|
|
93
84
|
};
|
|
94
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Strings drawn from each declared pattern by schema's one generator (`patternSamples`), kept only
|
|
88
|
+
* where every other pattern agrees. The text seed hashes with the pattern, so two columns differ.
|
|
89
|
+
*/
|
|
90
|
+
const fromPatterns = (property: string, ctx: ValueContext): string[] => {
|
|
91
|
+
const patterns = ctx.patterns ?? [];
|
|
92
|
+
if (patterns.length === 0) return [];
|
|
93
|
+
const { patternSamples } = sampler();
|
|
94
|
+
return patterns
|
|
95
|
+
.flatMap((pattern) =>
|
|
96
|
+
patternSamples(
|
|
97
|
+
pattern,
|
|
98
|
+
fnv1a(`${pattern.source}/${pattern.flags}:${property}:${ctx.canary}`) || 1,
|
|
99
|
+
),
|
|
100
|
+
)
|
|
101
|
+
.filter((sample) => patterns.every((pattern) => pattern.test(sample)));
|
|
102
|
+
};
|
|
103
|
+
|
|
95
104
|
/**
|
|
96
105
|
* Values the column's own schema accepts, seeded by the row so two rows differ and a rerun does
|
|
97
106
|
* not; then the bare `{}`/`[]`, which a schema-less `jsonb` column stores.
|
package/src/seed-graph.ts
CHANGED
|
@@ -63,6 +63,33 @@ function ownedByTenant(
|
|
|
63
63
|
return owned;
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
/**
|
|
67
|
+
* Every untenanted entity (`shared`, or of no tenant) a tenant-owned table points at through a key
|
|
68
|
+
* unique across the TABLE — a unique set holding the reference and not the tenant column. Each
|
|
69
|
+
* tenant's children then take parent rows no other tenant may reuse, so the parent seeds
|
|
70
|
+
* `perTable` rows per tenant: `perTable` alone ran out before tenant 2 (`X_SEED_GRAPH_UNSATISFIED`).
|
|
71
|
+
*/
|
|
72
|
+
function fannedOut(
|
|
73
|
+
shapes: ReadonlyMap<string, EntityShape>,
|
|
74
|
+
scoped: (name: string) => boolean,
|
|
75
|
+
): ReadonlySet<string> {
|
|
76
|
+
const out = new Set<string>();
|
|
77
|
+
for (const shape of shapes.values()) {
|
|
78
|
+
if (!scoped(shape.name)) continue;
|
|
79
|
+
const tenantColumn = shape.entity.$tenantColumn;
|
|
80
|
+
const tableWide = [...shape.uniques, ...shape.partialUniques].filter(
|
|
81
|
+
(set) => tenantColumn === null || !set.includes(tenantColumn),
|
|
82
|
+
);
|
|
83
|
+
for (const reference of shape.references.values()) {
|
|
84
|
+
if (scoped(reference.targetEntity) || reference.targetEntity === shape.name) continue;
|
|
85
|
+
if (tableWide.some((set) => set.includes(reference.property))) {
|
|
86
|
+
out.add(reference.targetEntity);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
|
|
66
93
|
export function seedGraph(
|
|
67
94
|
entities: Readonly<Record<string, SeedEntity>>,
|
|
68
95
|
options: SeedGraphOptions = {},
|
|
@@ -108,6 +135,7 @@ export function seedGraph(
|
|
|
108
135
|
}
|
|
109
136
|
const owned = ownedByTenant(shapes, roots);
|
|
110
137
|
const scoped = (name: string): boolean => owned.has(name);
|
|
138
|
+
const fanned = fannedOut(shapes, scoped);
|
|
111
139
|
for (const factory of Object.values(options.factories ?? {})) factory.reset();
|
|
112
140
|
|
|
113
141
|
const placed = new Map<string, Placed[]>();
|
|
@@ -170,7 +198,8 @@ export function seedGraph(
|
|
|
170
198
|
const buckets: readonly (string | null)[] = scoped(name) ? tenants : [null];
|
|
171
199
|
let ordinal = 0;
|
|
172
200
|
for (const [bucket, tenant] of buckets.entries()) {
|
|
173
|
-
const size =
|
|
201
|
+
const size =
|
|
202
|
+
rootProperty !== undefined ? 1 : fanned.has(name) ? perTable * tenantCount : perTable;
|
|
174
203
|
for (let index = 0; index < size; index += 1) {
|
|
175
204
|
ordinal += 1;
|
|
176
205
|
const canary = stream().replace(/-/g, '').slice(0, 10);
|
|
@@ -34,10 +34,19 @@ export function tenantFindingLines(records: readonly ExerciseRecord[]): readonly
|
|
|
34
34
|
);
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* B sends A's own first input, A's ids bound in only where it takes any: `{ occurrenceMs }` carries
|
|
39
|
+
* none, and B still crosses when a statement it ran names no tenant. Both readings, always.
|
|
40
|
+
*/
|
|
37
41
|
const causeOf = (record: ExerciseRecord, finding: TenantFinding): string =>
|
|
38
42
|
finding.code === 'X_TENANT_LEAK'
|
|
39
|
-
? `${record.kind} ${record.name}, called by tenant B with
|
|
40
|
-
: `${record.kind} ${record.name}, called by tenant B with
|
|
43
|
+
? `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, handed back A's seeded data at ${where(finding)}: a read reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`
|
|
44
|
+
: `${record.kind} ${record.name}, called by tenant B with A's own input ${LEG[finding.leg]}, changed A's ${where(finding)}: a write reached A's row past the tenant guard — by an id the caller chose, or by a statement no tenant predicate scopes`;
|
|
45
|
+
|
|
46
|
+
const fixOf = (name: string, finding: TenantFinding): string =>
|
|
47
|
+
finding.code === 'X_TENANT_LEAK'
|
|
48
|
+
? `x exercise ${name} --json — then read the row behind ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`
|
|
49
|
+
: `x exercise ${name} --json — then write ${where(finding)} through the repository scoped to ctx.actor, never by the input id alone, nor by an unscoped statement`;
|
|
41
50
|
|
|
42
51
|
/**
|
|
43
52
|
* One coded finding per tenant finding. The fix reruns the one primitive: what it reads or writes
|
|
@@ -54,7 +63,7 @@ export function tenantProofFindings(
|
|
|
54
63
|
(finding): TenantProofFinding => ({
|
|
55
64
|
code: finding.code,
|
|
56
65
|
cause: causeOf(record, finding),
|
|
57
|
-
fix:
|
|
66
|
+
fix: fixOf(name, finding),
|
|
58
67
|
at: `${record.kind} ${record.name}`,
|
|
59
68
|
}),
|
|
60
69
|
);
|
package/src/tenant-proof.ts
CHANGED
|
@@ -79,6 +79,12 @@ export interface TenantAttack {
|
|
|
79
79
|
* half is skipped. An action or a query answers B directly, so a declared scope excuses nothing.
|
|
80
80
|
*/
|
|
81
81
|
readonly sweep: boolean;
|
|
82
|
+
/**
|
|
83
|
+
* A job declaring `tenant: 'none'` (`bindJobTenant`'s `none`): no org reaches its run. Handed
|
|
84
|
+
* nothing of A's, B's run is A's run — what it writes to A's rows it writes for every enqueuer,
|
|
85
|
+
* so the write half's premise, B choosing A's row, cannot hold: skipped (`tenantlessReason`).
|
|
86
|
+
*/
|
|
87
|
+
readonly tenantless: boolean;
|
|
82
88
|
attack(leg: TenantLeg): Promise<LegAnswer>;
|
|
83
89
|
/** A's positive control: today's exercise of the primitive, and what A's last answer was. */
|
|
84
90
|
control(): Promise<{ readonly record: ExerciseRecord; readonly output: unknown }>;
|
|
@@ -129,11 +135,25 @@ export function sweepReason(
|
|
|
129
135
|
return `crossTenant(${JSON.stringify(reason)}) declares this job reads across tenants, and B's input named nothing of A's: read half skipped, write half kept`;
|
|
130
136
|
}
|
|
131
137
|
|
|
138
|
+
/**
|
|
139
|
+
* The reason the write half is skipped, or `undefined`: a `tenant: 'none'` job whose input carries
|
|
140
|
+
* none of A's marks — no org and no id of B's choosing reaches the run. Handed one of A's ids, B
|
|
141
|
+
* chose A's row and the write half stands. An action or a query runs as B's actor, so B's org
|
|
142
|
+
* reaches it: a write to A's row with no id handed is an unscoped statement, crossed as ever.
|
|
143
|
+
*/
|
|
144
|
+
export function tenantlessReason(
|
|
145
|
+
plan: Pick<TenantAttack, 'tenantless' | 'taint'>,
|
|
146
|
+
hunted: readonly string[],
|
|
147
|
+
): string | undefined {
|
|
148
|
+
if (!plan.tenantless || hunted.length !== plan.taint.marks.length) return undefined;
|
|
149
|
+
return "tenant: 'none' runs this job under no org, and B's input named nothing of A's: B's run is A's run, so what it writes it writes for every enqueuer — write half skipped, read half kept";
|
|
150
|
+
}
|
|
151
|
+
|
|
132
152
|
const verdictOf = (
|
|
133
153
|
plan: TenantAttack,
|
|
134
154
|
control: { readonly record: ExerciseRecord; readonly output: unknown },
|
|
135
155
|
findings: readonly TenantFinding[],
|
|
136
|
-
|
|
156
|
+
skipped: readonly string[],
|
|
137
157
|
): TenantProof => {
|
|
138
158
|
const { record } = control;
|
|
139
159
|
if (findings.length > 0) return { verdict: 'crossed', findings };
|
|
@@ -144,7 +164,7 @@ const verdictOf = (
|
|
|
144
164
|
};
|
|
145
165
|
}
|
|
146
166
|
if (plan.public !== undefined) return { verdict: 'public', reason: plan.public };
|
|
147
|
-
if (
|
|
167
|
+
if (skipped.length > 0) return { verdict: 'public', reason: skipped.join('; ') };
|
|
148
168
|
// Every mark of A's, handed ones included: A was handed its own ids, and reading them is the point.
|
|
149
169
|
if (plan.read && findTaint(control.output, 'output', plan.taint.marks).length === 0) {
|
|
150
170
|
return {
|
|
@@ -159,12 +179,19 @@ const verdictOf = (
|
|
|
159
179
|
export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
|
|
160
180
|
const marks = huntedMarks(plan.taint, plan.input);
|
|
161
181
|
const findings: TenantFinding[] = [];
|
|
182
|
+
const unwritten = tenantlessReason(plan, marks);
|
|
162
183
|
let swept: string | undefined;
|
|
163
|
-
|
|
184
|
+
/** B's call, between two snapshots unless the write half is skipped: then none is read. */
|
|
185
|
+
const watched = async (name: TenantLeg): Promise<LegAnswer> => {
|
|
186
|
+
if (unwritten !== undefined) return plan.attack(name);
|
|
164
187
|
const before = await plan.snapshot(plan.taint.rows);
|
|
165
188
|
const answer = await plan.attack(name);
|
|
166
189
|
const after = await plan.snapshot(plan.taint.rows);
|
|
167
190
|
findings.push(...crossedWrites(name, plan.taint, before, after));
|
|
191
|
+
return answer;
|
|
192
|
+
};
|
|
193
|
+
const leg = async (name: TenantLeg): Promise<void> => {
|
|
194
|
+
const answer = await watched(name);
|
|
168
195
|
if (plan.public !== undefined) return;
|
|
169
196
|
const sweep = sweepReason(plan, answer, marks);
|
|
170
197
|
if (sweep === undefined) findings.push(...leaksIn(name, answer, marks));
|
|
@@ -173,5 +200,6 @@ export async function proveTenant(plan: TenantAttack): Promise<ExerciseRecord> {
|
|
|
173
200
|
await leg('before');
|
|
174
201
|
const control = await plan.control();
|
|
175
202
|
await leg('after');
|
|
176
|
-
|
|
203
|
+
const skipped = [swept, unwritten].filter((reason) => reason !== undefined);
|
|
204
|
+
return { ...control.record, tenant: verdictOf(plan, control, findings, skipped) };
|
|
177
205
|
}
|