Skip to content

Commit e616c13

Browse files
committed
fix(cli-review): stop losing session events to jsonb-hostile characters
session_events INSERTs were failing with ERROR: unsupported Unicode escape sequence DETAIL: \u0000 cannot be converted to text. `payload` is whatever the CLI sent us — conversation text, tool output, LLM responses — and it regularly carries a U+0000. Postgres jsonb refuses it, the INSERT dies, and the event is dropped. Unpaired surrogates fail the same way with a different message ("Unicode low surrogate must follow a high surrogate"). Sanitising happens in SessionEventRepository.create, the single write path, so every caller is covered rather than just the ingest use case. It operates on the OBJECT, not on the serialised JSON. `JSON.stringify(x).replace(/\u0000/g, '')` — the shape this codebase already uses in pull-request-ingestion.service.ts — is a no-op for the json case: stringify has already turned the code point into the six-character text \u0000, so there is nothing left for that regex to match. Matching the escape as text instead would be worse, because a string that legitimately contains those six characters is valid jsonb and rewriting it would corrupt real data. Both cases are pinned by tests. Verified against Postgres before writing the fix: the raw form errors, the sanitised form inserts, and the escaped-backslash form was already fine. The repository test is mutation-checked — dropping the call turns it red.
1 parent 05b0799 commit e616c13

4 files changed

Lines changed: 304 additions & 1 deletion

File tree

libs/cli-review/infrastructure/repositories/session-event.repository.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
ClassificationSource,
77
} from './schemas/session-event.model';
88
import { CliSessionClassifiedDecision } from '@libs/cli-review/domain/types/cli-session-capture.types';
9+
import { sanitizeForJsonb } from '@libs/common/utils/jsonb-safe';
910

1011
@Injectable()
1112
export class SessionEventRepository {
@@ -15,7 +16,16 @@ export class SessionEventRepository {
1516
) {}
1617

1718
async create(data: Partial<SessionEventModel>): Promise<SessionEventModel> {
18-
const model = this.repo.create(data);
19+
// `payload` is whatever the CLI sent us — conversation text, tool
20+
// output, LLM responses. It regularly carries U+0000 and unpaired
21+
// surrogates, both of which the jsonb column refuses, and the
22+
// rejected INSERT drops the event. Sanitising at the repository
23+
// rather than in the use case keeps every writer covered.
24+
const model = this.repo.create(
25+
data.payload
26+
? { ...data, payload: sanitizeForJsonb(data.payload) }
27+
: data,
28+
);
1929
return this.repo.save(model);
2030
}
2131

libs/common/utils/jsonb-safe.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/**
2+
* Makes a value safe to store in a Postgres `jsonb` column.
3+
*
4+
* Postgres rejects two things that JavaScript strings happily carry, and
5+
* both reach us through `session_events.payload` — CLI/LLM conversation
6+
* text that nobody sanitised upstream:
7+
*
8+
* U+0000 ERROR: unsupported Unicode escape sequence
9+
* DETAIL: \u0000 cannot be converted to text.
10+
*
11+
* lone ERROR: invalid input syntax for type json
12+
* surrogate DETAIL: Unicode low surrogate must follow a high surrogate.
13+
*
14+
* The failing INSERT takes the whole row with it, so each occurrence is a
15+
* dropped event.
16+
*
17+
* WHY THIS OPERATES ON THE OBJECT AND NOT ON THE SERIALISED JSON
18+
*
19+
* The obvious-looking fix — `JSON.stringify(x).replace(/\u0000/g, '')` —
20+
* does nothing at all. `JSON.stringify` has already turned the real
21+
* U+0000 code point into the six-character text `\u0000`, so no U+0000 is
22+
* left in the output for that regex to match. (There is a copy of exactly
23+
* that no-op in
24+
* `libs/ee/analytics-warehouse/ingestion/pull-request-ingestion.service.ts`;
25+
* it does work for the plain-`text` column it also guards, just not for
26+
* the json one.)
27+
*
28+
* Matching the escape as text instead would be worse: a string that
29+
* legitimately contains the six characters `\u0000` serialises to
30+
* `\\u0000`, which Postgres accepts and stores as text. Rewriting that
31+
* would corrupt real data.
32+
*
33+
* Cleaning the strings before serialisation avoids both traps.
34+
*/
35+
36+
const NULL_CHAR = /\u0000/g;
37+
38+
/**
39+
* A high surrogate with no low surrogate after it, or a low surrogate
40+
* with no high surrogate before it. Well-formed pairs are left alone, so
41+
* emoji and other astral-plane characters survive untouched.
42+
*/
43+
const LONE_SURROGATE =
44+
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
45+
46+
/** U+FFFD REPLACEMENT CHARACTER — the standard stand-in for undecodable input. */
47+
const REPLACEMENT = '�';
48+
49+
const sanitizeString = (value: string): string =>
50+
value.replace(NULL_CHAR, '').replace(LONE_SURROGATE, REPLACEMENT);
51+
52+
/**
53+
* Recursion ceiling. Payloads are parsed request bodies, so they cannot
54+
* contain cycles, but a malformed one could still be deep enough to blow
55+
* the stack. Past the limit we drop the subtree rather than throw: losing
56+
* a nested corner of the payload beats losing the event, which is the
57+
* whole point of this module.
58+
*/
59+
const MAX_DEPTH = 64;
60+
61+
function sanitize(value: unknown, depth: number): unknown {
62+
if (typeof value === 'string') {
63+
return sanitizeString(value);
64+
}
65+
66+
if (value === null || typeof value !== 'object') {
67+
return value;
68+
}
69+
70+
if (depth >= MAX_DEPTH) {
71+
return null;
72+
}
73+
74+
// Dates carry no user text and JSON.stringify already handles them.
75+
if (value instanceof Date) {
76+
return value;
77+
}
78+
79+
if (Array.isArray(value)) {
80+
return value.map((item) => sanitize(item, depth + 1));
81+
}
82+
83+
const out: Record<string, unknown> = {};
84+
for (const [key, item] of Object.entries(value)) {
85+
// Keys land in the jsonb document too, so they need the same
86+
// treatment — a NUL in a key fails the INSERT just as hard.
87+
out[sanitizeString(key)] = sanitize(item, depth + 1);
88+
}
89+
return out;
90+
}
91+
92+
/**
93+
* Returns a copy of `value` with every string cleaned of the characters
94+
* Postgres `jsonb` refuses. The input is never mutated.
95+
*/
96+
export function sanitizeForJsonb<T>(value: T): T {
97+
return sanitize(value, 0) as T;
98+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { Test, TestingModule } from '@nestjs/testing';
2+
import { getRepositoryToken } from '@nestjs/typeorm';
3+
4+
import { SessionEventRepository } from '@libs/cli-review/infrastructure/repositories/session-event.repository';
5+
import { SessionEventModel } from '@libs/cli-review/infrastructure/repositories/schemas/session-event.model';
6+
7+
/**
8+
* Guards the wiring, not the sanitiser itself (that is covered in
9+
* test/unit/common/jsonb-safe.spec.ts). What matters here is that the
10+
* payload gets cleaned on the way to TypeORM — if the call is dropped
11+
* from `create`, these INSERTs go back to failing with
12+
* `unsupported Unicode escape sequence` and the event is lost.
13+
*/
14+
const NUL = String.fromCharCode(0);
15+
16+
describe('SessionEventRepository', () => {
17+
let repository: SessionEventRepository;
18+
let typeormRepo: { create: jest.Mock; save: jest.Mock };
19+
20+
beforeEach(async () => {
21+
typeormRepo = {
22+
create: jest.fn((x) => x),
23+
save: jest.fn(async (x) => x),
24+
};
25+
26+
const module: TestingModule = await Test.createTestingModule({
27+
providers: [
28+
SessionEventRepository,
29+
{
30+
provide: getRepositoryToken(SessionEventModel),
31+
useValue: typeormRepo,
32+
},
33+
],
34+
}).compile();
35+
36+
repository = module.get(SessionEventRepository);
37+
});
38+
39+
it('strips jsonb-hostile characters from payload before saving', async () => {
40+
await repository.create({
41+
sessionId: 's-1',
42+
payload: { prompt: `write${NUL} tests`, ok: true },
43+
} as Partial<SessionEventModel>);
44+
45+
const persisted = typeormRepo.create.mock.calls[0][0];
46+
47+
expect(persisted.payload).toEqual({ prompt: 'write tests', ok: true });
48+
expect(JSON.stringify(persisted)).not.toContain('\\u0000');
49+
});
50+
51+
it('leaves the rest of the row untouched', async () => {
52+
await repository.create({
53+
sessionId: 's-2',
54+
branch: 'main',
55+
payload: { a: 1 },
56+
} as Partial<SessionEventModel>);
57+
58+
const persisted = typeormRepo.create.mock.calls[0][0];
59+
60+
expect(persisted.sessionId).toBe('s-2');
61+
expect(persisted.branch).toBe('main');
62+
expect(persisted.payload).toEqual({ a: 1 });
63+
});
64+
65+
it('handles a row with no payload at all', async () => {
66+
await expect(
67+
repository.create({
68+
sessionId: 's-3',
69+
} as Partial<SessionEventModel>),
70+
).resolves.toBeDefined();
71+
72+
expect(typeormRepo.create.mock.calls[0][0].payload).toBeUndefined();
73+
});
74+
});
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
import { sanitizeForJsonb } from '@libs/common/utils/jsonb-safe';
2+
3+
/**
4+
* The characters under test are built with String.fromCharCode instead of
5+
* being typed literally: a raw U+0000 in a source file is invisible in
6+
* diffs and review, and editors love to eat it.
7+
*
8+
* Every expectation below was checked against a real Postgres 16 before
9+
* being written — `SELECT '{"a":"x\u0000y"}'::jsonb` fails with
10+
* `unsupported Unicode escape sequence`, and the sanitised form passes.
11+
*/
12+
const NUL = String.fromCharCode(0);
13+
const HIGH_SURROGATE = String.fromCharCode(0xd800);
14+
const LOW_SURROGATE = String.fromCharCode(0xdc00);
15+
const REPLACEMENT = String.fromCharCode(0xfffd);
16+
17+
/** What node-postgres ends up handing to Postgres. */
18+
const asJsonText = (value: unknown) => JSON.stringify(value);
19+
20+
describe('sanitizeForJsonb', () => {
21+
describe('the characters Postgres jsonb rejects', () => {
22+
it('removes U+0000 from string values', () => {
23+
const out = sanitizeForJsonb({ a: `x${NUL}y` });
24+
25+
expect(out).toEqual({ a: 'xy' });
26+
expect(asJsonText(out)).not.toContain('\\u0000');
27+
});
28+
29+
it('removes U+0000 from object keys', () => {
30+
const out = sanitizeForJsonb({ [`k${NUL}ey`]: 'v' });
31+
32+
expect(Object.keys(out)).toEqual(['key']);
33+
expect(asJsonText(out)).not.toContain('\\u0000');
34+
});
35+
36+
it('replaces an unpaired high surrogate', () => {
37+
const out = sanitizeForJsonb({ a: `x${HIGH_SURROGATE}y` });
38+
39+
expect(out).toEqual({ a: `x${REPLACEMENT}y` });
40+
});
41+
42+
it('replaces an unpaired low surrogate', () => {
43+
const out = sanitizeForJsonb({ a: `x${LOW_SURROGATE}y` });
44+
45+
expect(out).toEqual({ a: `x${REPLACEMENT}y` });
46+
});
47+
48+
it('reaches into nested objects and arrays', () => {
49+
const out = sanitizeForJsonb({
50+
turns: [{ text: `hi${NUL}` }, { text: 'ok' }],
51+
meta: { nested: { deep: `a${HIGH_SURROGATE}` } },
52+
});
53+
54+
expect(out).toEqual({
55+
turns: [{ text: 'hi' }, { text: 'ok' }],
56+
meta: { nested: { deep: `a${REPLACEMENT}` } },
57+
});
58+
});
59+
});
60+
61+
describe('what it must NOT touch', () => {
62+
it('keeps a literal backslash-u-0000 sequence intact', () => {
63+
// This is six ordinary characters, not a NUL. Postgres stores it
64+
// happily as text, so rewriting it would corrupt real data —
65+
// which is what a regex over the serialised JSON would do.
66+
const literal = 'C:\\u0000\\path';
67+
68+
expect(sanitizeForJsonb({ a: literal })).toEqual({ a: literal });
69+
});
70+
71+
it('keeps well-formed surrogate pairs (emoji) intact', () => {
72+
const emoji = 'ship it 🚀';
73+
74+
expect(sanitizeForJsonb({ a: emoji })).toEqual({ a: emoji });
75+
});
76+
77+
it('leaves non-string primitives alone', () => {
78+
const input = { n: 1, b: true, z: null, u: undefined };
79+
80+
expect(sanitizeForJsonb(input)).toEqual(input);
81+
});
82+
83+
it('does not mutate the input', () => {
84+
const input = { a: `x${NUL}y` };
85+
86+
sanitizeForJsonb(input);
87+
88+
expect(input.a).toBe(`x${NUL}y`);
89+
});
90+
});
91+
92+
describe('depth ceiling', () => {
93+
it('drops the subtree past the limit instead of throwing', () => {
94+
let deep: Record<string, unknown> = { text: `end${NUL}` };
95+
for (let i = 0; i < 200; i++) {
96+
deep = { nested: deep };
97+
}
98+
99+
expect(() => sanitizeForJsonb(deep)).not.toThrow();
100+
expect(asJsonText(sanitizeForJsonb(deep))).not.toContain('\\u0000');
101+
});
102+
});
103+
104+
describe('the trap this replaces', () => {
105+
it('shows why sanitising after JSON.stringify is a no-op', () => {
106+
const raw = { a: `x${NUL}y` };
107+
108+
// The pattern used elsewhere in the codebase: stringify first,
109+
// then strip U+0000. By then there is no U+0000 left to strip —
110+
// stringify turned it into the text \u0000.
111+
const stringifiedThenStripped = JSON.stringify(raw).replace(
112+
new RegExp(NUL, 'g'),
113+
'',
114+
);
115+
expect(stringifiedThenStripped).toContain('\\u0000');
116+
117+
// Sanitising the object first is what actually removes it.
118+
expect(asJsonText(sanitizeForJsonb(raw))).not.toContain('\\u0000');
119+
});
120+
});
121+
});

0 commit comments

Comments
 (0)