From 24b786963965b107331d4ba091bb778e3ea0a775 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Oct 2026 20:00:50 +0000 Subject: [PATCH 1/2] fix(deparser): escape single quotes in bit-string literals (#355) A_Const.bsval (wrapped, unwrapped, val.BitString) and BitString nodes were interpolated raw into b'...'/x'...', so a quote in a programmatically built value closed the literal early. Route all sites through formatBitString, which doubles single quotes like the sval path. --- .../__tests__/misc/bit-string.test.ts | 33 ++++++++++++++ packages/deparser/src/deparser.ts | 43 +++++++------------ 2 files changed, 48 insertions(+), 28 deletions(-) create mode 100644 packages/deparser/__tests__/misc/bit-string.test.ts diff --git a/packages/deparser/__tests__/misc/bit-string.test.ts b/packages/deparser/__tests__/misc/bit-string.test.ts new file mode 100644 index 00000000..cf0899fd --- /dev/null +++ b/packages/deparser/__tests__/misc/bit-string.test.ts @@ -0,0 +1,33 @@ +import { Deparser } from '../../src'; +import { expectParseDeparse } from '../../test-utils'; + +const payload = "x' OR '1'='1"; + +const selectConst = (aConst: any) => ({ + SelectStmt: { + targetList: [{ ResTarget: { val: { A_Const: aConst } } }], + limitOption: 'LIMIT_OPTION_DEFAULT', + op: 'SETOP_NONE' + } +}); + +describe('bit-string literals', () => { + it('round-trips binary and hex bit strings', async () => { + await expectParseDeparse(`SELECT B'1010', X'1F'`); + }); + + it.each([ + ['b-prefixed', { bsval: { bsval: `b${payload}` } }, `SELECT b'x'' OR ''1''=''1'`], + ['x-prefixed', { bsval: { bsval: `x1F' OR '1'='1` } }, `SELECT x'1F'' OR ''1''=''1'`], + ['unprefixed', { bsval: { bsval: `1' OR '1'='1` } }, `SELECT b'1'' OR ''1''=''1'`], + ['unwrapped', { bsval: `b${payload}` }, `SELECT b'x'' OR ''1''=''1'`], + ['val.BitString', { val: { BitString: { bsval: `b${payload}` } } }, `SELECT b'x'' OR ''1''=''1'`] + ])('escapes single quotes in %s A_Const bsval', (_label, aConst, expected) => { + expect(Deparser.deparse(selectConst(aConst) as any)).toBe(expected); + }); + + it('escapes single quotes in BitString nodes', () => { + const deparser = new Deparser([]); + expect(deparser.BitString({ bsval: `b${payload}` }, {} as any)).toBe(`b'x'' OR ''1''=''1'`); + }); +}); diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index b74a721a..b319ec96 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -147,6 +147,17 @@ function isWrappedParseResult(obj: any): obj is { ParseResult: t.ParseResult } { * The deparser automatically detects bare ParseResult objects for backward * compatibility and wraps them internally for consistent processing. */ +function formatBitString(bsval: string): string { + const quote = (value: string) => value.replace(/'/g, "''"); + if (bsval.startsWith('x')) { + return `x'${quote(bsval.substring(1))}'`; + } + if (bsval.startsWith('b')) { + return `b'${quote(bsval.substring(1))}'`; + } + return `b'${quote(bsval)}'`; +} + export class Deparser implements DeparserVisitor { private tree: Node[]; private options: DeparserOptions; @@ -1812,15 +1823,7 @@ export class Deparser implements DeparserVisitor { } else if (nodeAny.bsval !== undefined) { if (typeof nodeAny.bsval === 'object' && nodeAny.bsval !== null) { if (nodeAny.bsval.bsval !== undefined) { - const bsval = nodeAny.bsval.bsval; - // Check if this is a hexadecimal bit string (starts with x and contains only hex digits) - if (bsval.startsWith('x') && /^x[0-9A-Fa-f]+$/.test(bsval)) { - return `x'${bsval.substring(1)}'`; - } - if (bsval.startsWith('b')) { - return `b'${bsval.substring(1)}'`; - } - return `b'${bsval}'`; + return formatBitString(nodeAny.bsval.bsval); } else if (Object.keys(nodeAny.bsval).length === 0) { return "''"; } else { @@ -1829,15 +1832,7 @@ export class Deparser implements DeparserVisitor { } else if (nodeAny.bsval === null) { return 'NULL'; } else { - const bsval = nodeAny.bsval; - // Check if this is a hexadecimal bit string (starts with x and contains only hex digits) - if (bsval.startsWith('x') && /^x[0-9A-Fa-f]+$/.test(bsval)) { - return `x'${bsval.substring(1)}'`; - } - if (bsval.startsWith('b')) { - return `b'${bsval.substring(1)}'`; - } - return `b'${bsval}'`; + return formatBitString(nodeAny.bsval); } } @@ -1851,7 +1846,7 @@ export class Deparser implements DeparserVisitor { } else if (nodeAny.val.Boolean?.boolval !== undefined) { return nodeAny.val.Boolean.boolval ? 'true' : 'false'; } else if (nodeAny.val.BitString?.bsval !== undefined) { - return nodeAny.val.BitString.bsval; + return formatBitString(nodeAny.val.BitString.bsval); } } @@ -2615,15 +2610,7 @@ export class Deparser implements DeparserVisitor { } BitString(node: t.BitString, context: DeparserContext): string { - // Check if this is a hexadecimal bit string (starts with x) - if (node.bsval.startsWith('x')) { - return `x'${node.bsval.substring(1)}'`; - } - if (node.bsval.startsWith('b')) { - return `b'${node.bsval.substring(1)}'`; - } - // Fallback for raw values without prefix - return `b'${node.bsval}'`; + return formatBitString(node.bsval); } Null(node: t.Node, context: DeparserContext): string { From b5ecd89031e4f9450a5411f3b2c2ffd095b6ba93 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Mon, 5 Oct 2026 20:17:28 +0000 Subject: [PATCH 2/2] fix(deparser): keep hex validation for x-prefixed bit strings --- packages/deparser/__tests__/misc/bit-string.test.ts | 2 +- packages/deparser/src/deparser.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/deparser/__tests__/misc/bit-string.test.ts b/packages/deparser/__tests__/misc/bit-string.test.ts index cf0899fd..80e85788 100644 --- a/packages/deparser/__tests__/misc/bit-string.test.ts +++ b/packages/deparser/__tests__/misc/bit-string.test.ts @@ -18,7 +18,7 @@ describe('bit-string literals', () => { it.each([ ['b-prefixed', { bsval: { bsval: `b${payload}` } }, `SELECT b'x'' OR ''1''=''1'`], - ['x-prefixed', { bsval: { bsval: `x1F' OR '1'='1` } }, `SELECT x'1F'' OR ''1''=''1'`], + ['x-prefixed', { bsval: { bsval: `x1F' OR '1'='1` } }, `SELECT b'x1F'' OR ''1''=''1'`], ['unprefixed', { bsval: { bsval: `1' OR '1'='1` } }, `SELECT b'1'' OR ''1''=''1'`], ['unwrapped', { bsval: `b${payload}` }, `SELECT b'x'' OR ''1''=''1'`], ['val.BitString', { val: { BitString: { bsval: `b${payload}` } } }, `SELECT b'x'' OR ''1''=''1'`] diff --git a/packages/deparser/src/deparser.ts b/packages/deparser/src/deparser.ts index b319ec96..f430cd49 100644 --- a/packages/deparser/src/deparser.ts +++ b/packages/deparser/src/deparser.ts @@ -149,8 +149,8 @@ function isWrappedParseResult(obj: any): obj is { ParseResult: t.ParseResult } { */ function formatBitString(bsval: string): string { const quote = (value: string) => value.replace(/'/g, "''"); - if (bsval.startsWith('x')) { - return `x'${quote(bsval.substring(1))}'`; + if (/^x[0-9A-Fa-f]+$/.test(bsval)) { + return `x'${bsval.substring(1)}'`; } if (bsval.startsWith('b')) { return `b'${quote(bsval.substring(1))}'`;