From 0e33eb736d522820c61226e1815bf5c847d2d688 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 25 Oct 2026 03:43:50 +0000 Subject: [PATCH] fix(scan): escape control characters and throw SqlError on lexer errors --- templates/full/index.ts | 8 ++++---- templates/full/wasm_wrapper.c | 10 ++++++---- versions/18/src/index.ts | 8 ++++---- versions/18/src/wasm_wrapper.c | 10 ++++++---- versions/18/test/scan.test.js | 16 ++++++++++++++++ 5 files changed, 36 insertions(+), 16 deletions(-) diff --git a/templates/full/index.ts b/templates/full/index.ts index e97b609..67b540e 100644 --- a/templates/full/index.ts +++ b/templates/full/index.ts @@ -537,8 +537,8 @@ export const scan = awaitInit(async (query: string): Promise => { resultPtr = callWasm(() => wasmModule._wasm_scan(queryPtr)); const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + if (!resultStr.startsWith('{')) { + throw new SqlError(resultStr); } return JSON.parse(resultStr); @@ -564,8 +564,8 @@ export function scanSync(query: string): ScanResult { resultPtr = callWasm(() => wasmModule._wasm_scan(queryPtr)); const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + if (!resultStr.startsWith('{')) { + throw new SqlError(resultStr); } return JSON.parse(resultStr); diff --git a/templates/full/wasm_wrapper.c b/templates/full/wasm_wrapper.c index 268d289..4698534 100644 --- a/templates/full/wasm_wrapper.c +++ b/templates/full/wasm_wrapper.c @@ -272,14 +272,14 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi } // Size the buffer from token text length, not token count: each token - // contributes at most ~170 bytes of fixed JSON plus up to 2 bytes per input - // byte once escaped. + // contributes at most ~170 bytes of fixed JSON plus up to 6 bytes per input + // byte once escaped (\u00XX for control characters). size_t estimated_size = 1024; for (size_t i = 0; i < scan_result->n_tokens; i++) { PgQuery__ScanToken *token = scan_result->tokens[i]; int token_length = token->end - token->start; if (token_length < 0) token_length = 0; - estimated_size += (size_t) token_length * 2 + 200; + estimated_size += (size_t) token_length * 6 + 200; } char* json = safe_malloc(estimated_size); if (!json) { @@ -305,7 +305,7 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi token_text[token_length] = '\0'; // Escape token text for JSON - char* escaped_text = safe_malloc(token_length * 2 + 1); + char* escaped_text = safe_malloc(token_length * 6 + 1); if (!escaped_text) { free(token_text); continue; @@ -326,6 +326,8 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi } else if (c == '\t') { escaped_text[escaped_pos++] = '\\'; escaped_text[escaped_pos++] = 't'; + } else if ((unsigned char) c < 0x20) { + escaped_pos += sprintf(escaped_text + escaped_pos, "\\u%04x", (unsigned char) c); } else { escaped_text[escaped_pos++] = c; } diff --git a/versions/18/src/index.ts b/versions/18/src/index.ts index 56c93d6..a3ab075 100644 --- a/versions/18/src/index.ts +++ b/versions/18/src/index.ts @@ -544,8 +544,8 @@ export const scan = awaitInit(async (query: string): Promise => { resultPtr = callWasm(() => wasmModule._wasm_scan(queryPtr)); const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + if (!resultStr.startsWith('{')) { + throw new SqlError(resultStr); } return JSON.parse(resultStr); @@ -571,8 +571,8 @@ export function scanSync(query: string): ScanResult { resultPtr = callWasm(() => wasmModule._wasm_scan(queryPtr)); const resultStr = ptrToString(resultPtr); - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); + if (!resultStr.startsWith('{')) { + throw new SqlError(resultStr); } return JSON.parse(resultStr); diff --git a/versions/18/src/wasm_wrapper.c b/versions/18/src/wasm_wrapper.c index 63ae5da..30a2cf4 100644 --- a/versions/18/src/wasm_wrapper.c +++ b/versions/18/src/wasm_wrapper.c @@ -279,14 +279,14 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi } // Size the buffer from token text length, not token count: each token - // contributes at most ~170 bytes of fixed JSON plus up to 2 bytes per input - // byte once escaped. + // contributes at most ~170 bytes of fixed JSON plus up to 6 bytes per input + // byte once escaped (\u00XX for control characters). size_t estimated_size = 1024; for (size_t i = 0; i < scan_result->n_tokens; i++) { PgQuery__ScanToken *token = scan_result->tokens[i]; int token_length = token->end - token->start; if (token_length < 0) token_length = 0; - estimated_size += (size_t) token_length * 2 + 200; + estimated_size += (size_t) token_length * 6 + 200; } char* json = safe_malloc(estimated_size); if (!json) { @@ -312,7 +312,7 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi token_text[token_length] = '\0'; // Escape token text for JSON - char* escaped_text = safe_malloc(token_length * 2 + 1); + char* escaped_text = safe_malloc(token_length * 6 + 1); if (!escaped_text) { free(token_text); continue; @@ -333,6 +333,8 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi } else if (c == '\t') { escaped_text[escaped_pos++] = '\\'; escaped_text[escaped_pos++] = 't'; + } else if ((unsigned char) c < 0x20) { + escaped_pos += sprintf(escaped_text + escaped_pos, "\\u%04x", (unsigned char) c); } else { escaped_text[escaped_pos++] = c; } diff --git a/versions/18/test/scan.test.js b/versions/18/test/scan.test.js index bd92787..3c624cf 100644 --- a/versions/18/test/scan.test.js +++ b/versions/18/test/scan.test.js @@ -303,5 +303,21 @@ comment */ SELECT 2`; assert.equal(t.text, `$$${literal}$$`); } }); + + it("should escape control characters in token text", () => { + for (const c of ["\x01", "\b", "\f", "\x1f"]) { + const sql = `SELECT 'a${c}b', $$${c}$$`; + const result = query.scanSync(sql); + assert.deepEqual(result.tokens.map(t => t.text), ["SELECT", `'a${c}b'`, ",", `$$${c}$$`]); + } + }); + + it("should throw SqlError for input the lexer rejects", async () => { + for (const sql of ["SELECT 'unterminated", "SELECT $$x", "SELECT /* x", ""]) { + assert.throws(() => query.scanSync(sql), query.SqlError, JSON.stringify(sql)); + await assert.rejects(query.scan(sql), query.SqlError, JSON.stringify(sql)); + } + assert.equal(query.scanSync("SELECT 1").tokens.length, 2); + }); }); });