Skip to content

Commit 6d5191e

Browse files
fix(core): an import row for the sandbox's own fault reads the door's fault sentence (#22741) (#22867)
Fixes #22741 Clause-②: yes (widening) ## What An import row for a fault the QuickJS sandbox raises itself now reads the create door's fault sentence instead of the runner's diagnostic text. These faults are the CPU budget, the wall-clock ceiling, a denied capability, an unavailable `ctx.api`, a failed install or marshal, and a nested body's crash relayed across a VM hop. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row relayed `.message` instead, for example `hook 'mz_lock_insert' exceeded CPU budget of 50ms (after 0 pump iterations)`. ## Landing point, and why no runner marker - **Predicate:** `isSandboxOwnFault(error)` in `packages/types/src/data-error-classification.ts`, beside `isSandboxOrigin`. It answers `true` when `name === 'SandboxError'` and `isSandboxOrigin` is false (no non-empty `innerMessage`). Both halves are read off the producer. `quickjs-runner.ts` sets `this.name = 'SandboxError'`, and its `innerMessage` contract says the field is undefined for "the sandbox's own internal errors (capability denials, timeouts, marshalling failures)". The producer already marks its own faults, so the runner needs no new marker and `packages/runtime` is untouched. - **Why the class name and not `instanceof`:** `@objectstack/runtime` depends on `@objectstack/core` (`packages/runtime/package.json`), so core cannot import the class. `isEngineDuplicateRecordEnvelope` in the same file recognises a class by its declared contract for the same reason, and `objectql`'s `hook-withheld-readonly-fault.ts` already reads `'SandboxError'` by name. - **Row:** `toFailedResult` (`packages/core/src/utils/import-runner.ts`) now computes `sandboxed = isSandboxOrigin(err) || isSandboxOwnFault(err)`, and takes the door's sentence when `sandboxed && door.status >= 500`. That is the rule PR #22740 applies to a crash. ⛔ There is no second withholding rule: `mapDataError` still decides which errors are faults and what their sentence is. The row's `code` stays `IMPORT_ROW_FAILED`. - ⛔ Still not "every door 5xx": a driver error is neither a sandbox origin nor a sandbox fault, and its row keeps `sanitizeRowError` text. ## Measured before the fix This ran through the real `QuickJSScriptRunner` at `eed637c09f`, feeding the real `mapDataError` and `runImport` (a scratch test, not committed). Import object `mz_locked`: | fault (real runner) | `name` / `innerMessage` | create door | row before | |---|---|---|---| | CPU budget (`for(;;){}`, 50ms) | `SandboxError` / undefined | 500 `Internal server error` | `hook 'mz_lock_insert' exceeded CPU budget of 50ms (after 0 pump iterations)` | | wall-clock ceiling (never-settling host call) | `SandboxError` / undefined | 500 `Internal server error` | `hook 'mz_lock_insert' exceeded wall-clock ceiling of 300ms while awaiting host calls (after 40 pump iterations)` | | `api.read` denied on another object | `SandboxError` / undefined | 500 `Internal server error` | `capability 'api.read' not granted to hook 'mz_lock_insert' (called ctx.api.object('mz_other').find)` | | `log` denied | `SandboxError` / undefined | 500 `Internal server error` | `capability 'log' not granted to hook 'mz_lock_insert'` | | missing `ctx.api` method | `SandboxError` / undefined | 500 `Internal server error` | `ctx.api.object('mz_other').find not implemented` | | `api.read` denied on the import's own object | `SandboxError` / undefined | **404 `OBJECT_NOT_FOUND`** `Object 'mz_locked' is not registered` | the capability text | The last row is a separate door defect (see Acceptance notes). The row does not copy a 4xx sentence, so it keeps the capability text there. ## The enumeration pin `packages/core/src/utils/import-runner-sandbox-fault-row.test.ts` sits in core's `repo` vitest project and is listed in `vitest.repo-tests.json`, because it reads `packages/runtime/src/sandbox/quickjs-runner.ts`. Core's declared radius `packages/**/*.ts` already covers that read. The test parses the runner with the TypeScript compiler, so the two `throw new SandboxError(` examples inside doc comments are not sites. - **§1, 15 fault sites:** every `new SandboxError(…)` with no `innerMessage` (`:235`, `:240`, `:660`, `:891`, `:917`, `:1025`, `:1104`, `:1199`, `:1553`) and every `throwSandboxFault(…)` call (`:634`, `:730`, `:756`, `:803`, `:1173`, `:1180`). Each message is rendered from its own template literal with a fixed sample per interpolation. Each case first asserts the door's verdict (`500`, `{ error: 'Internal server error', code: 'INTERNAL_ERROR' }`, the text withheld) as anti-vacuity. It then asserts `isSandboxOwnFault` and that the row equals `{ row: 1, ok: false, action: 'failed', error: DOOR_SENTENCE, code: 'IMPORT_ROW_FAILED' }`. The batched `insertManyData` path is driven once for the budget fault. - **The census:** the two non-literal single-argument constructions (`message` in `throwSandboxFault`, and `withoutSandboxErrorPrefix(String(errStr))` in the pump loop) must be exactly the declared relays. A site the pin cannot render, such as a new interpolation with no sample, is red and the failure names it. Each named kind keeps its measured floor: CPU budget 1, wall-clock 1, capability denial 5, `ctx.api` unavailable 1, install 4, marshal 1, nested transaction 1, missing method 1. The class `name` is read from the producer's constructor, so a rename there reaches every case. - **§2, the relay half:** the four constructions that do set `innerMessage` (`:280`, `:349`, `:403`, `:448`) are rendered after a nested body crashed (`TypeError: boom`). That is the `.message` the outer pump rebuilds as a fault with no `innerMessage`. The door withholds it, and the row takes the door's sentence. - **§3, controls:** the same budget text on a plain `Error` keeps `sanitizeRowError`'s reading (the predicate reads class identity, never the message). A code-defined hook's native `TypeError` keeps its text. Driver text keeps `sanitizeRowError`'s reading although its door answers 5xx. A body's refusal or crash, and non-error values, answer `false`. `import-runner-sandbox-refusal-row.test.ts` §4, the driver-text control PR #22740's ablation B pinned, is not edited. ## Ablations All were run from committed HEAD `2180c575bf` through `node scripts/ablation-replace.mjs`. In each, the anchor hit once and moved from 1 to 0, the blob changed, and the restore was proven with the blob equal to HEAD and `git diff HEAD` empty. The command was `pnpm --filter @objectstack/core exec vitest run --project repo --maxWorkers=2 src/utils/import-runner-sandbox-fault-row.test.ts`. | leg | mutation | result | |---|---|---| | A | `toFailedResult`: `isSandboxOrigin(err) \|\| isSandboxOwnFault(err)` becomes `isSandboxOrigin(err)` | **20 failed / 5 passed**: every §1 site, the batched path and all four §2 relays went red; the census and the 4 controls stayed green | | B | `isSandboxOwnFault` body becomes `return false && …` | **20 failed / 5 passed** | | C | producer `this.name = 'SandboxError'` becomes `'SandboxFault'` in `quickjs-runner.ts` | **20 failed / 5 passed**: the cross-package name contract is pinned | | D | a new fault site the pin cannot render: `:660` becomes a template with `${txState.depth}` | **1 failed / 23 passed**: the census, naming `quickjs-runner.ts:660: no sample for the interpolation txState.depth` | The test imports `./import-runner` relatively, and core's vitest aliases `@objectstack/types` to its source, so no `dist/` sits on these paths. The runner is read from disk. ## Tests All on HEAD `bb09729a0a` (the branch merged with `origin/main` `55382dc02a`). The dependency closure was built first with `pnpm --filter '@objectstack/core...' build` (exit 0). - `pnpm --filter @objectstack/core exec vitest run --project local --project repo --maxWorkers=2`: **100 files / 2451 tests passed** (all 100 test files in the package, both projects). - `pnpm --filter @objectstack/types exec vitest run --project local --project repo --maxWorkers=2`: **28 files / 765 tests passed**. - `pnpm --filter @objectstack/types typecheck`: exit 0. `pnpm --filter @objectstack/core typecheck`: exit 0. The test layer stays at its 4 pinned signatures, and the new test adds none. - The new file alone: 25/25 passed (the census, 15 §1 sites, the batched path, 4 §2 relays, 4 controls). - **Gates:** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 66 families from this diff, and all 66 exit 0. Two of them, `check:dual-build-cjs-loads` and `check:lean-entry-closure`, first answered `PREREQUISITE NOT MET` (exit 3, not measured). Both were re-run green on the same HEAD after `check:type-check-debt`'s full build. `--ran` reconciliation: 66 derived, 66 run, 0 NOT-MEASURED (a derived zero, since every line carries its exit code). Re-run on `23a4a8a83f` (the changeset commit): the same 66 families, all exit 0, none exit 3; `--ran` 66 derived, 66 run, 0 NOT-MEASURED. - **Lint, a declared narrowing:** `pnpm exec eslint --no-inline-config --format json` on the three changed `.ts` files gives 3 files, 0 errors and 0 warnings (the count is read from the JSON). The population was read from ESLint itself: `isPathIgnored` is false and `calculateConfigForFile` yields rules for all three. This repo never enables type-aware linting (`parserOptions.project` is null for all three, and `eslint.config.mjs` says so), so the diff cannot move the verdict on any untouched file. The repo-wide `pnpm lint` is left to CI. - **CI:** `in_progress` when this PR opened, and not waited on. ## Acceptance notes - **A door defect, outside this card:** `mapDataError`'s unknown-object heuristic, `lower.includes("'OBJECT'") && lower.includes('not')`, answers `404 OBJECT_NOT_FOUND` `Object 'OBJECT' is not registered` for a sandbox fault whose text names the requested object. A capability denial on that object, such as `capability 'api.read' not granted to hook '…' (called ctx.api.object('OBJECT').find)`, does. It was measured through the real runner as shown above. The row does not copy that 4xx, so it keeps the capability text there. If the door is fixed to 500, the row follows with no change here. This is reported to the seat for filing. - **Population versus triage's count:** triage named 8 no-`innerMessage` sites (`:235`, `:240`, `:660`, `:891`, `:917`, `:1025`, `:1104`, `:1199`). The enumeration also finds the marshal failure (`:1553`), the six `throwSandboxFault` capability and `ctx.api` sites, and the two relays (`:432`, `:1524`). The PM's 17 `new SandboxError` lines are 15 constructions plus 2 inside doc comments. - **Mechanism note:** `isSandboxOrigin` is not module-private. It is in the file's trailing `export { … }` block, so `@objectstack/types` already exports it, and core imported it before this PR. - **Public surface:** `@objectstack/types`' `.` entry gains one export, `isSandboxOwnFault(error: unknown): boolean`. It was measured on the built `dist/index.d.ts` (the declaration and the export list) and on `dist/index.d.mts`. No other name changes, and nothing re-exports `@objectstack/types` wholesale (`git grep`), so it cannot collide. The seat settled the line as `Clause-②: yes (widening)`. The changeset declares it too and takes `minor` for `@objectstack/types`; `@objectstack/core` stays `patch`. - **Test wiring:** `packages/core/vitest.repo-tests.json` gains the new file, which the core `repo` project requires for a test that reads outside the package. --- _Generated by [Claude Code](https://claude.ai/code/session_01ADzJtzYTLUfgrRZHxkagkX)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d952358 commit 6d5191e

5 files changed

Lines changed: 419 additions & 3 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/core': patch
3+
'@objectstack/types': minor
4+
---
5+
6+
An import row for the sandbox's own fault reads the create door's fault sentence, not the runner's diagnostic text (#22741)
7+
8+
Clause-②: yes (widening)
9+
10+
The QuickJS runner raises some `SandboxError`s itself: a hook or action body that runs past its CPU budget or past the wall-clock ceiling, calls a capability it was not granted, or meets a failed install or marshal. These carry no `innerMessage`. `POST /api/v1/data/:object` and `/createMany` answer them `500 INTERNAL_ERROR` `Internal server error`. The sync `/import` row and the `/import/jobs` stored row answered the runner's text instead, for example `hook 'guard' exceeded CPU budget of 50ms (after 3 pump iterations)`.
11+
12+
- **`@objectstack/types`** exports `isSandboxOwnFault(error)`. It is `true` for an error whose `name` is `'SandboxError'` and whose `innerMessage` is not a non-empty string, which is how the runner marks the faults it raises itself. A body's refusal or crash carries `innerMessage` and answers `false`.
13+
- **`@objectstack/core`**'s import row applies the rule a crashed body already gets: when the sandbox raised the error and the create door answers it with a 5xx, the row's `error` is the door's sentence. The row's `code` is unchanged (`IMPORT_ROW_FAILED`). A driver error, and an error a code-defined hook threw, keep the row text they had.
Lines changed: 357 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,357 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22741] An import row for the sandbox's OWN fault reads the create door's
5+
* fault sentence, never the runner's diagnostic text. This is the enumeration
6+
* pin that closes the import-row versus data-door fault-text family (#22694 the
7+
* refusal, #22718 the crash and the declared 5xx, this card the rest).
8+
*
9+
* ## What was measured broken
10+
*
11+
* The REAL `QuickJSScriptRunner`, at `eed637c09f`, fed to the real
12+
* `mapDataError` and `runImport`: a hook body that burns its CPU budget, one
13+
* stuck past the wall-clock ceiling, one denied `api.read`, one denied `log`
14+
* and one whose `ctx.api` method is missing each threw a `SandboxError` with no
15+
* `innerMessage`. The create door answered every one `500 INTERNAL_ERROR`
16+
* `Internal server error`; the row answered the runner's text, e.g.
17+
* `hook 'mz_lock_insert' exceeded CPU budget of 50ms (after 0 pump iterations)`.
18+
* `toFailedResult` takes the door's sentence only for a sandbox ORIGIN (a
19+
* non-empty `innerMessage`), and these errors have none.
20+
*
21+
* ## Why an enumeration, and what it iterates
22+
*
23+
* The population is read from the producer, not listed here.
24+
* `packages/runtime/src/sandbox/quickjs-runner.ts` is parsed with the
25+
* TypeScript compiler, and every `new SandboxError(…)` that passes no
26+
* `innerMessage` is a site, as is every `throwSandboxFault(vm, …)` call, whose
27+
* `SandboxError` the pump loop rebuilds with no `innerMessage` once the fault
28+
* has crossed the VM. Each site's message is rendered from its own template
29+
* literal, with a fixed sample per interpolation. So:
30+
*
31+
* - a new fault site is in the population the day it lands, and turns this
32+
* file red if its row does not read the door's sentence;
33+
* - a site this file cannot render (an interpolation with no sample, a
34+
* message that is not a literal and not a declared relay) is red too, and
35+
* the failure names it.
36+
*
37+
* Comments are not code: the two `throw new SandboxError(` examples inside the
38+
* runner's doc comments are not sites, because the parser never sees them.
39+
*
40+
* The class is REPRODUCED, because `@objectstack/core` sits below
41+
* `@objectstack/runtime`, but its `name` is READ from the producer's
42+
* constructor (`this.name = '…'`). A rename there reaches every case below.
43+
*
44+
* §2 runs the relay half: a construction that DOES set `innerMessage`, after
45+
* its body crashed one VM hop down, arrives at the outer pump as a fault with
46+
* that construction's `.message` and no `innerMessage`. §3 holds the controls,
47+
* which stay unchanged by this card: driver text, and errors the sandbox did
48+
* not raise. `import-runner-sandbox-refusal-row.test.ts` §4 is the driver-text
49+
* control #22718's ablation B pinned, and it is not edited here.
50+
*
51+
* The sample object name in a rendered message is deliberately NOT the
52+
* import's object. `mapDataError`'s unknown-object heuristic answers `404
53+
* OBJECT_NOT_FOUND` for a text that names the requested object and contains
54+
* `not`, which a capability denial on that object does. That is a door
55+
* defect outside this card; the row does not copy a 4xx sentence.
56+
*/
57+
58+
import { readFileSync } from 'node:fs';
59+
import { dirname, resolve } from 'node:path';
60+
import { fileURLToPath } from 'node:url';
61+
import ts from 'typescript';
62+
import { describe, it, expect, vi } from 'vitest';
63+
import { mapDataError, INTERNAL_ERROR_MESSAGE, isSandboxOwnFault } from '@objectstack/types';
64+
import { runImport, sanitizeRowError, type ImportProtocolLike } from './import-runner';
65+
import type { ExportFieldMeta } from './import-field-meta.js';
66+
67+
const HERE = dirname(fileURLToPath(import.meta.url));
68+
const RUNNER = resolve(HERE, '../../../runtime/src/sandbox/quickjs-runner.ts');
69+
const SOURCE = ts.createSourceFile(RUNNER, readFileSync(RUNNER, 'utf8'), ts.ScriptTarget.Latest, true);
70+
71+
/**
72+
* One sample per interpolation the runner's fault messages use. A message
73+
* naming an expression not listed here is unrenderable, and §1's census says
74+
* which. `objectName` is not the import's object (see the header).
75+
*/
76+
const SAMPLES: Readonly<Record<string, string>> = {
77+
'args.origin.kind': 'hook',
78+
'args.origin.name': 'mz_lock_insert',
79+
'origin.kind': 'hook',
80+
'origin.name': 'mz_lock_insert',
81+
cpuBudget: '50',
82+
wallCeiling: '30000',
83+
pumps: '3',
84+
'formatErr(msg)': 'SyntaxError: unexpected token',
85+
objectName: 'mz_other',
86+
method: 'find',
87+
required: 'api.read',
88+
fieldName: 'owner',
89+
};
90+
91+
/** §2: the error a nested body threw, so the construction it lands in is a CRASH. */
92+
const CRASH_SAMPLES: Readonly<Record<string, string>> = {
93+
...SAMPLES,
94+
'formatErr(err)': 'TypeError: boom',
95+
errStr: 'TypeError: boom',
96+
};
97+
98+
/**
99+
* The single-argument constructions whose message is not a literal: each one
100+
* re-raises a fault that other sites produce. A relay missing from the
101+
* runner, or a new non-literal one, fails §1's census.
102+
*/
103+
const RELAYS: Readonly<Record<string, string>> = {
104+
message: '`throwSandboxFault` builds the error for each of its call sites, which are sites here in their own right',
105+
'withoutSandboxErrorPrefix(String(errStr))':
106+
'the pump loop rebuilds a fault marked across the VM hop: the `throwSandboxFault` messages, the host-side faults thrown inside a host call (sites here), and a nested body\'s crash (§2)',
107+
};
108+
109+
/** Each named fault kind, with the number of sites measured at `eed637c09f` as its floor. */
110+
const KINDS: ReadonlyArray<readonly [kind: string, pattern: RegExp, floor: number]> = [
111+
['CPU budget', /exceeded CPU budget/, 1],
112+
['wall-clock ceiling', /exceeded wall-clock ceiling/, 1],
113+
['capability denial', /^capability '/, 5],
114+
['ctx.api unavailable', /^ctx\.api unavailable/, 1],
115+
['install failure', /^failed to install/, 4],
116+
['marshal failure', /^failed to marshal/, 1],
117+
['nested transaction refusal', /^nested ctx\.api\.transaction/, 1],
118+
['missing ctx.api method', /not implemented$/, 1],
119+
];
120+
121+
interface Site {
122+
label: string;
123+
message: string;
124+
}
125+
126+
function lineOf(node: ts.Node): number {
127+
return SOURCE.getLineAndCharacterOfPosition(node.getStart()).line + 1;
128+
}
129+
130+
function render(node: ts.Expression, samples: Readonly<Record<string, string>>): string {
131+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
132+
if (ts.isParenthesizedExpression(node)) return render(node.expression, samples);
133+
if (ts.isTemplateExpression(node)) {
134+
let out = node.head.text;
135+
for (const span of node.templateSpans) {
136+
const expr = span.expression.getText();
137+
if (!(expr in samples)) throw new Error(`no sample for the interpolation \`${expr}\``);
138+
out += samples[expr] + span.literal.text;
139+
}
140+
return out;
141+
}
142+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
143+
return render(node.left, samples) + render(node.right, samples);
144+
}
145+
throw new Error(`a message that is neither a literal nor a declared relay: \`${node.getText()}\``);
146+
}
147+
148+
/** The `name` the producer's constructor assigns, read from the source. */
149+
function producerName(): string | undefined {
150+
let found: string | undefined;
151+
const visit = (node: ts.Node): void => {
152+
if (ts.isClassDeclaration(node) && node.name?.text === 'SandboxError') {
153+
for (const member of node.members) {
154+
if (!ts.isConstructorDeclaration(member) || member.body === undefined) continue;
155+
for (const st of member.body.statements) {
156+
if (!ts.isExpressionStatement(st) || !ts.isBinaryExpression(st.expression)) continue;
157+
const { left, right, operatorToken } = st.expression;
158+
if (
159+
operatorToken.kind === ts.SyntaxKind.EqualsToken &&
160+
ts.isPropertyAccessExpression(left) &&
161+
left.expression.kind === ts.SyntaxKind.ThisKeyword &&
162+
left.name.text === 'name' &&
163+
ts.isStringLiteral(right)
164+
) {
165+
found = right.text;
166+
}
167+
}
168+
}
169+
}
170+
ts.forEachChild(node, visit);
171+
};
172+
visit(SOURCE);
173+
return found;
174+
}
175+
176+
const census = (() => {
177+
const faults: Site[] = [];
178+
const crashes: Site[] = [];
179+
const relays: string[] = [];
180+
const unrenderable: string[] = [];
181+
const visit = (node: ts.Node): void => {
182+
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'SandboxError') {
183+
const args = node.arguments ?? [];
184+
const at = `quickjs-runner.ts:${lineOf(node)}`;
185+
if (args.length === 1) {
186+
const text = args[0].getText();
187+
if (text in RELAYS) {
188+
relays.push(text);
189+
} else {
190+
try {
191+
faults.push({ label: `${at} new SandboxError(…)`, message: render(args[0], SAMPLES) });
192+
} catch (e) {
193+
unrenderable.push(`${at}: ${(e as Error).message}`);
194+
}
195+
}
196+
} else if (args.length >= 2) {
197+
try {
198+
crashes.push({ label: `${at} new SandboxError(…, innerMessage), relayed`, message: render(args[0], CRASH_SAMPLES) });
199+
} catch (e) {
200+
unrenderable.push(`${at} (relayed): ${(e as Error).message}`);
201+
}
202+
} else {
203+
unrenderable.push(`${at}: a construction with no message`);
204+
}
205+
}
206+
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === 'throwSandboxFault') {
207+
const at = `quickjs-runner.ts:${lineOf(node)}`;
208+
try {
209+
faults.push({ label: `${at} throwSandboxFault(…)`, message: render(node.arguments[1], SAMPLES) });
210+
} catch (e) {
211+
unrenderable.push(`${at}: ${(e as Error).message}`);
212+
}
213+
}
214+
ts.forEachChild(node, visit);
215+
};
216+
visit(SOURCE);
217+
return { faults, crashes, relays, unrenderable };
218+
})();
219+
220+
const PRODUCER_NAME = producerName();
221+
222+
/** The producer's own fault: its declared `name`, and no `innerMessage`. */
223+
function ownFault(message: string): Error {
224+
const err = new Error(message);
225+
err.name = PRODUCER_NAME ?? 'unread';
226+
return err;
227+
}
228+
229+
type CreateArgs = Parameters<ImportProtocolLike['createData']>[0];
230+
231+
const OBJECT = 'mz_locked';
232+
233+
const baseOpts = {
234+
objectName: OBJECT,
235+
metaMap: new Map<string, ExportFieldMeta>([['name', { name: 'name', type: 'text' }]]),
236+
writeMode: 'insert' as const,
237+
matchFields: [] as string[],
238+
dryRun: false,
239+
runAutomations: false,
240+
trimWhitespace: true,
241+
createMissingOptions: false,
242+
skipBlankMatchKey: false,
243+
};
244+
245+
/** Inline path: no bulk primitive, so each row is one `createData`. */
246+
function inlineProtocol(fail: () => Error): ImportProtocolLike {
247+
return {
248+
findData: vi.fn(async () => []),
249+
createData: vi.fn(async (args: CreateArgs) => {
250+
if (args.data.name === 'r1') throw fail();
251+
return { id: `id_${String(args.data.name)}` };
252+
}),
253+
updateData: vi.fn(),
254+
};
255+
}
256+
257+
async function rowFor(fail: () => Error) {
258+
const summary = await runImport({ ...baseOpts, p: inlineProtocol(fail), rows: [{ name: 'r1' }] });
259+
expect(summary.errors).toBe(1);
260+
return summary.results[0];
261+
}
262+
263+
/** The door's verdict FIRST: a fault, its text withheld. Anti-vacuity for every row assertion below. */
264+
function expectDoorWithholds(err: Error, message: string) {
265+
const door = mapDataError(err, OBJECT);
266+
expect(door.status).toBe(500);
267+
expect(door.body).toEqual({ error: INTERNAL_ERROR_MESSAGE, code: 'INTERNAL_ERROR' });
268+
expect(String(door.body.error)).not.toContain(message);
269+
return door;
270+
}
271+
272+
describe('[#22741] §1 — every fault the runner raises itself reads the door\'s sentence on the row', () => {
273+
it('census: the producer is read, every construction is classified, and each named kind is present', () => {
274+
expect(PRODUCER_NAME, 'the `this.name = …` assignment in SandboxError\'s constructor').toBeTypeOf('string');
275+
expect(census.unrenderable, 'sites this pin cannot render; add a sample or a relay').toEqual([]);
276+
expect([...new Set(census.relays)].sort()).toEqual(Object.keys(RELAYS).sort());
277+
for (const [kind, pattern, floor] of KINDS) {
278+
const n = census.faults.filter((s) => pattern.test(s.message)).length;
279+
expect(n, `${kind}: sites found`).toBeGreaterThanOrEqual(floor);
280+
}
281+
// §2's population: the constructions that set `innerMessage` (crash and refusal sites).
282+
expect(census.crashes.length).toBeGreaterThanOrEqual(4);
283+
});
284+
285+
it.each(census.faults.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => {
286+
const err = ownFault(message);
287+
expect(isSandboxOwnFault(err)).toBe(true);
288+
const door = expectDoorWithholds(err, message);
289+
290+
const row = await rowFor(() => ownFault(message));
291+
expect(row).toEqual({ row: 1, ok: false, action: 'failed', error: door.body.error, code: 'IMPORT_ROW_FAILED' });
292+
});
293+
294+
it('the batched write paths reach the same answer (the budget fault)', async () => {
295+
const budget = census.faults.find((s) => /exceeded CPU budget/.test(s.message))!.message;
296+
const insertManyData = vi.fn(async (args: { records: Array<Record<string, unknown>> }) => ({
297+
outcomes: args.records.map((r) => (r.name === 'r1'
298+
? { ok: false, error: ownFault(budget) }
299+
: { ok: true, record: { id: `id_${String(r.name)}` } })),
300+
}));
301+
const p = { ...inlineProtocol(() => ownFault(budget)), createManyData: vi.fn(), insertManyData } as unknown as ImportProtocolLike;
302+
const summary = await runImport({ ...baseOpts, p, rows: [{ name: 'r0' }, { name: 'r1' }, { name: 'r2' }] });
303+
304+
expect(insertManyData).toHaveBeenCalledTimes(1);
305+
expect(summary.created).toBe(2);
306+
expect(summary.results[1]).toEqual({ row: 2, ok: false, action: 'failed', error: INTERNAL_ERROR_MESSAGE, code: 'IMPORT_ROW_FAILED' });
307+
expect(JSON.stringify(summary)).not.toMatch(/exceeded CPU budget|mz_lock_insert/);
308+
});
309+
});
310+
311+
describe('[#22741] §2 — a nested body\'s crash, relayed one VM hop up as the sandbox\'s fault', () => {
312+
it.each(census.crashes.map((s) => [s.label, s.message] as const))('%s', async (_label, message) => {
313+
const err = ownFault(message);
314+
expect(isSandboxOwnFault(err)).toBe(true);
315+
const door = expectDoorWithholds(err, message);
316+
317+
const row = await rowFor(() => ownFault(message));
318+
expect(row).toEqual({ row: 1, ok: false, action: 'failed', error: door.body.error, code: 'IMPORT_ROW_FAILED' });
319+
expect(JSON.stringify(row)).not.toMatch(/TypeError|boom|threw:/);
320+
});
321+
});
322+
323+
describe('[#22741] §3 — controls: what the predicate must NOT change', () => {
324+
const budget = () => census.faults.find((s) => /exceeded CPU budget/.test(s.message))!.message;
325+
326+
it('CONTROL — the same text on an error the sandbox did not raise keeps the row\'s own reading', async () => {
327+
// The predicate reads the producer's class identity, never the message.
328+
const err = () => new Error(budget());
329+
expect(isSandboxOwnFault(err())).toBe(false);
330+
expect(mapDataError(err(), OBJECT).status).toBe(500);
331+
expect((await rowFor(err)).error).toBe(sanitizeRowError(budget()));
332+
});
333+
334+
it('CONTROL — a code-defined hook\'s native TypeError keeps its text, as the filing says', async () => {
335+
const text = "Cannot read properties of undefined (reading 'owner')";
336+
const err = () => new TypeError(text);
337+
expect(isSandboxOwnFault(err())).toBe(false);
338+
expect((await rowFor(err)).error).toBe(text);
339+
});
340+
341+
it('CONTROL — driver text keeps `sanitizeRowError`\'s reading although its door answers 5xx', async () => {
342+
const sql = "insert into `mz_locked` (`name`) values ('r1') - SQLITE_BUSY: database is locked";
343+
expect(isSandboxOwnFault(new Error(sql))).toBe(false);
344+
expect(mapDataError(new Error(sql), OBJECT).status).toBeGreaterThanOrEqual(500);
345+
expect((await rowFor(() => new Error(sql))).error).toBe('SQLITE_BUSY: database is locked');
346+
});
347+
348+
it('CONTROL — a sandboxed body\'s refusal or crash is the origin rule\'s, not this predicate\'s', () => {
349+
const refusal = Object.assign(ownFault("hook 'g' threw: Error: Locked."), { innerMessage: 'Locked.' });
350+
const crash = Object.assign(ownFault("hook 'g' threw: TypeError: boom"), { innerMessage: 'TypeError: boom' });
351+
expect(isSandboxOwnFault(refusal)).toBe(false);
352+
expect(isSandboxOwnFault(crash)).toBe(false);
353+
for (const value of [undefined, null, 'SandboxError', { name: 'SandboxErrorX' }]) {
354+
expect(isSandboxOwnFault(value), String(value)).toBe(false);
355+
}
356+
});
357+
});

0 commit comments

Comments
 (0)