Files
gbrain/test/links-timeline-jsonb-poison.test.ts
T
ecd6ae8772 v0.42.40.0 fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011) (#2031)
* fix(extract,ingest): well-form lone UTF-16 surrogates before jsonb (#2011)

excerpt() in link-extraction.ts sliced the link-context window by raw UTF-16
index, so a boundary landing inside a non-BMP char (emoji, math, CJK) left an
unpaired surrogate half in `context`. Serialized to JSONB for the
jsonb_to_recordset batch insert, Postgres rejects it at the ::jsonb cast and
aborts the whole batch — wedging `extract --stale` because the staleness
bookmark only advances on a clean finish.

- text-safe.ts: new ensureWellFormed() (Bun isWellFormed/toWellFormed) — one
  shared surrogate-cleaning primitive.
- link-extraction.ts: excerpt() well-forms the slice (root-cause fix).
- batch-rows.ts: new sanitizeForJsonb() = ensureWellFormed(stripNul(s)) applied
  to every free-text body field (link context; timeline summary/detail/source;
  take claim/source). Identity/security fields stay un-sanitized and fail closed.
- postgres-engine.ts + pglite-engine.ts: scalar addLink + addTimelineEntry use
  sanitizeForJsonb too, matching the batch path on both engines.
- brainstorm/orchestrator.ts: consolidate hand-rolled sanitizeUnicode onto
  ensureWellFormed (also fixes consecutive-lone-surrogate mishandling).

Tests: ensureWellFormed unit cases (incl. consecutive lone surrogates), an
excerpt window-split regression, PGLite + Postgres-e2e surrogate cases across
all free-text fields and scalar paths, and fail-closed identity-field tests
proving sanitization was NOT extended to slugs/holders.

* v0.42.39.0 chore: bump version and changelog (#2011)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: update project documentation for v0.42.39.0

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* v0.42.40.0 chore: re-slot release version (was 0.42.39.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: fix env-mutation isolation violations in retrieval-reflex tests

check:test-isolation (rule R1) flags direct process.env mutation in
non-serial test files — bun's parallel runner loads multiple files into
one process, so a leaked GBRAIN_RETRIEVAL_REFLEX flips reflex behavior
in unrelated tests. Both files landed via the #2019 merge; convert the
beforeEach/afterEach env juggling to the canonical withEnv() wrapper,
which restores the prior value via try/finally even on throw.

Fixes the failing `verify` CI check on #2031 (and the `test-status`
aggregate that inherits it). All 30 verify checks green locally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 22:00:36 -07:00

353 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// gbrain#1861 regression — batch inserts must survive free-text "poison"
// payloads (calendar/Zoom context: commas, quotes, backslashes, braces,
// em-dashes) that the old unnest(${arr}::text[]) array-literal path rejected
// with Postgres "malformed array literal".
//
// PGLite half (always-on). PGLite uses a different array serializer than
// postgres.js, so it may not reproduce the ORIGINAL crash — but it locks the
// new jsonb_to_recordset path's behavior everywhere CI runs. The Postgres lane
// (test/e2e/jsonb-batch-poison-postgres.test.ts) is the one that actually
// crashed pre-fix.
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
// The exact shape from the #1861 crash report: Zoom URL with ?pwd=, commas,
// double-quotes, a Windows backslash path, braces, and an em-dash.
const POISON =
'Zoom: https://zoom.us/j/95178948505?pwd=YmdFRWxXbWZadlNkaG9iNC9CYW12QT09, ' +
'"Q2 sync" — notes {a,b}, path C:\\Users\\x, emdash } and { trailing';
// U+0000 — Postgres jsonb rejects it; the row builders strip it. Built via
// fromCharCode so no literal NUL byte ever lands in this source file.
const NUL = String.fromCharCode(0);
// #2011 — a lone UTF-16 high surrogate (no low partner). This is what excerpt()
// left behind when a context window boundary sliced an emoji; Postgres rejects
// it inside the ::jsonb cast and aborts the whole batch. The row builders now
// well-form it to U+FFFD. Built via fromCharCode so no lone surrogate is ever a
// literal in this source file. PGLite serializes jsonb differently and may not
// reproduce the original Postgres crash, but it still locks the JS-side
// sanitization (buildLinkRows/...): the stored value must be well-formed.
const LONE_HI = String.fromCharCode(0xd83c);
const SURROGATE = `before${LONE_HI}after`;
const SURROGATE_CLEAN = 'beforeafter';
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
async function seed(slug: string) {
await engine.putPage(slug, {
title: slug, type: 'concept' as never,
compiled_truth: 'body long enough to pass any minimum length backstop',
timeline: '', frontmatter: {}, source_path: `${slug}.md`,
});
}
async function pageId(slug: string): Promise<number> {
const rows = await engine.executeRaw<{ id: number }>(
`SELECT id FROM pages WHERE slug = $1 AND source_id = 'default'`, [slug],
);
return rows[0].id;
}
describe('addLinksBatch — JSONB poison (#1861)', () => {
it('round-trips calendar free-text context without throwing', async () => {
await seed('from-page');
await seed('to-page');
const n = await engine.addLinksBatch([
{ from_slug: 'from-page', to_slug: 'to-page', link_type: 'mentions',
context: POISON, link_source: 'manual' },
]);
expect(n).toBe(1);
const links = await engine.getLinks('from-page', { sourceId: 'default' });
expect(links).toHaveLength(1);
expect(links[0].context).toBe(POISON); // byte-identical
});
it('strips embedded NUL but preserves the rest', async () => {
await seed('a'); await seed('b');
await engine.addLinksBatch([
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions',
context: `before${NUL}after`, link_source: 'manual' },
]);
const links = await engine.getLinks('a', { sourceId: 'default' });
expect(links[0].context).toBe('beforeafter');
});
it('null origin_slug leaves origin_page_id NULL', async () => {
await seed('a'); await seed('b');
await engine.addLinksBatch([
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', link_source: 'manual' },
]);
const rows = await engine.executeRaw<{ origin_page_id: number | null }>(
`SELECT l.origin_page_id FROM links l JOIN pages p ON p.id = l.from_page_id
WHERE p.slug = 'a'`,
);
expect(rows[0].origin_page_id).toBeNull();
});
it('collapses an intra-batch duplicate (ON CONFLICT DO NOTHING)', async () => {
await seed('a'); await seed('b');
const dup = { from_slug: 'a', to_slug: 'b', link_type: 'mentions',
context: POISON, link_source: 'manual' as const };
const n = await engine.addLinksBatch([dup, dup]);
expect(n).toBe(1);
const links = await engine.getLinks('a', { sourceId: 'default' });
expect(links).toHaveLength(1);
});
it('round-trips a non-null link_kind through the jsonb recordset', async () => {
await seed('a'); await seed('b');
await engine.addLinksBatch([
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', context: POISON,
link_source: 'mentions', link_kind: 'typed_ner' },
]);
const rows = await engine.executeRaw<{ link_kind: string | null }>(
`SELECT l.link_kind FROM links l JOIN pages p ON p.id = l.from_page_id
WHERE p.slug = 'a'`,
);
expect(rows[0].link_kind).toBe('typed_ner'); // new recordset column, exercised non-NULL
});
it('leaves link_kind NULL when omitted (legacy/plain)', async () => {
await seed('a'); await seed('b');
await engine.addLinksBatch([
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', link_source: 'manual' },
]);
const rows = await engine.executeRaw<{ link_kind: string | null }>(
`SELECT l.link_kind FROM links l JOIN pages p ON p.id = l.from_page_id
WHERE p.slug = 'a'`,
);
expect(rows[0].link_kind).toBeNull();
});
});
describe('addTimelineEntriesBatch — JSONB poison (#1861)', () => {
it('round-trips free-text summary/detail/source without throwing', async () => {
await seed('meeting-page');
const n = await engine.addTimelineEntriesBatch([
{ slug: 'meeting-page', date: '2026-06-04', source: POISON,
summary: POISON, detail: POISON },
]);
expect(n).toBe(1);
const rows = await engine.executeRaw<{ summary: string; detail: string; source: string }>(
`SELECT te.summary, te.detail, te.source FROM timeline_entries te
JOIN pages p ON p.id = te.page_id WHERE p.slug = 'meeting-page'`,
);
expect(rows[0].summary).toBe(POISON);
expect(rows[0].detail).toBe(POISON);
expect(rows[0].source).toBe(POISON);
});
it('strips embedded NUL in summary', async () => {
await seed('m');
await engine.addTimelineEntriesBatch([
{ slug: 'm', date: '2026-06-04', summary: `a${NUL}b`, detail: '' },
]);
const rows = await engine.executeRaw<{ summary: string }>(
`SELECT te.summary FROM timeline_entries te JOIN pages p ON p.id = te.page_id
WHERE p.slug = 'm'`,
);
expect(rows[0].summary).toBe('ab');
});
});
describe('addTakesBatch — JSONB poison + native-type parity (#1861)', () => {
it('round-trips free-text claim and native number/bool/null fields', async () => {
await seed('take-page');
const pid = await pageId('take-page');
const n = await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: POISON, kind: 'fact', holder: 'garry',
weight: 0.74, active: false }, // since_date/until_date/source omitted -> null
]);
expect(n).toBe(1);
const rows = await engine.executeRaw<{
claim: string; weight: number | string; active: boolean; since_date: string | null;
}>(`SELECT claim, weight, active, since_date FROM takes t
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'take-page' AND t.row_num = 1`);
expect(rows[0].claim).toBe(POISON); // free-text claim survives
expect(Number(rows[0].weight)).toBeCloseTo(0.75, 5); // 0.74 -> 0.05 grid
expect(rows[0].active).toBe(false); // JSON boolean round-trips
expect(rows[0].since_date).toBeNull(); // omitted -> SQL NULL
});
it('strips embedded NUL from the free-text claim', async () => {
await seed('tp-nul');
const pid = await pageId('tp-nul');
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: `a${NUL}b`, kind: 'fact', holder: 'h' },
]);
const rows = await engine.executeRaw<{ claim: string }>(
`SELECT claim FROM takes t JOIN pages p ON p.id = t.page_id
WHERE p.slug = 'tp-nul' AND t.row_num = 1`,
);
expect(rows[0].claim).toBe('ab');
});
it('clamps out-of-range weight (>1) to 1.0', async () => {
await seed('tp2');
const pid = await pageId('tp2');
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h', weight: 1.5 },
]);
const rows = await engine.executeRaw<{ weight: number | string }>(
`SELECT weight FROM takes t JOIN pages p ON p.id = t.page_id
WHERE p.slug = 'tp2' AND t.row_num = 1`,
);
expect(Number(rows[0].weight)).toBeCloseTo(1.0, 5);
});
it('DO UPDATE upserts an existing (page_id, row_num)', async () => {
await seed('tp3');
const pid = await pageId('tp3');
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: 'first', kind: 'fact', holder: 'h' },
]);
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: 'second', kind: 'fact', holder: 'h' },
]);
const rows = await engine.executeRaw<{ claim: string; n: number | string }>(
`SELECT claim, (SELECT count(*) FROM takes t2 JOIN pages p2 ON p2.id = t2.page_id
WHERE p2.slug = 'tp3') AS n
FROM takes t JOIN pages p ON p.id = t.page_id WHERE p.slug = 'tp3' AND t.row_num = 1`,
);
expect(rows[0].claim).toBe('second');
expect(Number(rows[0].n)).toBe(1);
});
it('round-trips nullable/native take fields (superseded_by, until_date, non-null source)', async () => {
await seed('tp4');
const pid = await pageId('tp4');
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h',
superseded_by: 7, until_date: '2027-01-01', source: POISON },
]);
const rows = await engine.executeRaw<{
superseded_by: number | string | null; until_date: string | null; source: string;
}>(`SELECT superseded_by, until_date::text AS until_date, source FROM takes t
JOIN pages p ON p.id = t.page_id WHERE p.slug = 'tp4' AND t.row_num = 1`);
expect(Number(rows[0].superseded_by)).toBe(7); // native int column
expect(rows[0].until_date).toBe('2027-01-01'); // text/date round-trip
expect(rows[0].source).toBe(POISON); // non-null free-text source
});
});
describe('scalar addLink — NUL strip on free-text context (#1861 codex #3)', () => {
it('addLink strips NUL from context', async () => {
await seed('a'); await seed('b');
await engine.addLink('a', 'b', `before${NUL}after`, 'mentions', 'manual');
const links = await engine.getLinks('a', { sourceId: 'default' });
expect(links[0].context).toBe('beforeafter');
});
});
describe('#2011 — lone-surrogate well-forming across all prose write paths', () => {
it('batch link context: lone surrogate well-formed, insert succeeds', async () => {
await seed('a'); await seed('b');
const n = await engine.addLinksBatch([
{ from_slug: 'a', to_slug: 'b', link_type: 'mentions', context: SURROGATE, link_source: 'manual' },
]);
expect(n).toBe(1);
const links = await engine.getLinks('a', { sourceId: 'default' });
expect(links[0].context).toBe(SURROGATE_CLEAN);
expect(links[0].context.isWellFormed()).toBe(true);
});
it('batch timeline summary/detail/source: all lone surrogates well-formed', async () => {
await seed('m');
const n = await engine.addTimelineEntriesBatch([
{ slug: 'm', date: '2026-06-04', source: SURROGATE, summary: SURROGATE, detail: SURROGATE },
]);
expect(n).toBe(1);
const rows = await engine.executeRaw<{ summary: string; detail: string; source: string }>(
`SELECT te.summary, te.detail, te.source FROM timeline_entries te
JOIN pages p ON p.id = te.page_id WHERE p.slug = 'm'`,
);
expect(rows[0].summary).toBe(SURROGATE_CLEAN);
expect(rows[0].detail).toBe(SURROGATE_CLEAN);
expect(rows[0].source).toBe(SURROGATE_CLEAN); // D2: timeline source now sanitized
expect(rows[0].source.isWellFormed()).toBe(true);
});
it('batch take claim: lone surrogate well-formed', async () => {
await seed('tk');
const pid = await pageId('tk');
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: SURROGATE, kind: 'fact', holder: 'h' },
]);
const rows = await engine.executeRaw<{ claim: string }>(
`SELECT claim FROM takes t JOIN pages p ON p.id = t.page_id
WHERE p.slug = 'tk' AND t.row_num = 1`,
);
expect(rows[0].claim).toBe(SURROGATE_CLEAN);
expect(rows[0].claim.isWellFormed()).toBe(true);
});
it('batch take source (free-text provenance): lone surrogate well-formed', async () => {
await seed('tks');
const pid = await pageId('tks');
await engine.addTakesBatch([
{ page_id: pid, row_num: 1, claim: 'c', kind: 'fact', holder: 'h', source: SURROGATE },
]);
const rows = await engine.executeRaw<{ source: string }>(
`SELECT source FROM takes t JOIN pages p ON p.id = t.page_id
WHERE p.slug = 'tks' AND t.row_num = 1`,
);
expect(rows[0].source).toBe(SURROGATE_CLEAN);
expect(rows[0].source.isWellFormed()).toBe(true);
});
it('scalar addLink: lone surrogate in context well-formed', async () => {
await seed('sa'); await seed('sb');
await engine.addLink('sa', 'sb', SURROGATE, 'mentions', 'manual');
const links = await engine.getLinks('sa', { sourceId: 'default' });
expect(links[0].context).toBe(SURROGATE_CLEAN);
expect(links[0].context.isWellFormed()).toBe(true);
});
it('scalar addTimelineEntry: lone surrogate in summary/detail/source well-formed', async () => {
await seed('sm');
await engine.addTimelineEntry('sm', {
date: '2026-06-05', source: SURROGATE, summary: SURROGATE, detail: SURROGATE,
});
const rows = await engine.executeRaw<{ summary: string; detail: string; source: string }>(
`SELECT te.summary, te.detail, te.source FROM timeline_entries te
JOIN pages p ON p.id = te.page_id WHERE p.slug = 'sm'`,
);
expect(rows[0].summary).toBe(SURROGATE_CLEAN);
expect(rows[0].detail).toBe(SURROGATE_CLEAN);
expect(rows[0].source).toBe(SURROGATE_CLEAN);
});
it('fail-closed: identity fields are NOT sanitized — a lone surrogate in to_slug rejects the batch', async () => {
// The NUL/surrogate policy deliberately leaves identity fields raw so a
// malformed slug can never be silently normalized into one that matches a
// different page. This PGLite build rejects the lone surrogate at the
// ::jsonb cast (22P02) — a loud failure, exactly the intended boundary
// (and proof we did NOT extend sanitizeForJsonb to identity columns).
await seed('idfrom'); await seed('idto');
await expect(
engine.addLinksBatch([
{ from_slug: 'idfrom', to_slug: `idto${LONE_HI}`, link_type: 'mentions', link_source: 'manual' },
]),
).rejects.toThrow();
const links = await engine.getLinks('idfrom', { sourceId: 'default' });
expect(links).toHaveLength(0);
});
});