From ca67fa62146c2198424c62a5ae5f27eec03f2145 Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sun, 25 Oct 2026 03:42:57 +0000 Subject: [PATCH] fix(v18): throw errors from fingerprint, normalize and scan instead of returning them --- templates/full/index.ts | 49 +++++++++++----------------- templates/full/wasm_wrapper.c | 45 +++++++++++++++---------- versions/18/src/index.ts | 49 +++++++++++----------------- versions/18/src/wasm_wrapper.c | 45 +++++++++++++++---------- versions/18/test/fingerprint.test.js | 7 ++++ versions/18/test/normalize.test.js | 7 ++++ versions/18/test/scan.test.js | 16 +++++++++ 7 files changed, 122 insertions(+), 96 deletions(-) diff --git a/templates/full/index.ts b/templates/full/index.ts index e97b6096..c24a139c 100644 --- a/templates/full/index.ts +++ b/templates/full/index.ts @@ -244,6 +244,19 @@ function ptrToString(ptr: number): string { return wasmModule.UTF8ToString(ptr); } +// fingerprint, normalize and scan return "ERROR: " on failure and a +// null pointer when the WASM heap is exhausted. +function resultOrThrow(resultPtr: number): string { + if (!resultPtr) { + throw new Error('libpg_query returned no result: out of WASM memory (heap limit is WASM_MAXIMUM_MEMORY, 1 GiB); pass large inputs in smaller pieces'); + } + const resultStr = ptrToString(resultPtr); + if (resultStr.startsWith('ERROR: ')) { + throw new Error(resultStr.slice('ERROR: '.length)); + } + return resultStr; +} + export const parse = awaitInit(async (query: string): Promise => { // Input validation if (query === null || query === undefined) { @@ -338,11 +351,7 @@ export const fingerprint = awaitInit(async (query: string): Promise => { try { resultPtr = callWasm(() => wasmModule._wasm_fingerprint(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -362,11 +371,7 @@ export const normalize = awaitInit(async (query: string): Promise => { try { resultPtr = callWasm(() => wasmModule._wasm_normalize_query(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -484,11 +489,7 @@ export function fingerprintSync(query: string): string { try { resultPtr = callWasm(() => wasmModule._wasm_fingerprint(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -511,11 +512,7 @@ export function normalizeSync(query: string): string { try { resultPtr = callWasm(() => wasmModule._wasm_normalize_query(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -535,11 +532,7 @@ export const scan = awaitInit(async (query: string): Promise => { try { 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); - } + const resultStr = resultOrThrow(resultPtr); return JSON.parse(resultStr); } finally { @@ -562,11 +555,7 @@ export function scanSync(query: string): ScanResult { try { 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); - } + const resultStr = resultOrThrow(resultPtr); return JSON.parse(resultStr); } finally { diff --git a/templates/full/wasm_wrapper.c b/templates/full/wasm_wrapper.c index 268d2893..d3a99360 100644 --- a/templates/full/wasm_wrapper.c +++ b/templates/full/wasm_wrapper.c @@ -26,6 +26,15 @@ static void* safe_malloc(size_t size) { return ptr; } +// fingerprint, normalize and scan return errors as "ERROR: " so the +// caller can tell them from a result, and NULL when out of memory. +static char* error_result(const char* message) { + size_t len = strlen(message) + sizeof("ERROR: "); + char* result = safe_malloc(len); + if (result) snprintf(result, len, "ERROR: %s", message); + return result; +} + EMSCRIPTEN_KEEPALIVE char* wasm_parse_query(const char* input) { if (!validate_input(input)) { @@ -107,15 +116,15 @@ char* wasm_parse_plpgsql(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_fingerprint(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return error_result("Invalid input: query cannot be null or empty"); } PgQueryFingerprintResult result = pg_query_fingerprint(input); if (result.error) { - char* error_msg = safe_strdup(result.error->message); + char* error_msg = error_result(result.error->message); pg_query_free_fingerprint_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + return error_msg; } char* fingerprint_str = safe_strdup(result.fingerprint_str); @@ -126,24 +135,20 @@ char* wasm_fingerprint(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_normalize_query(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return error_result("Invalid input: query cannot be null or empty"); } PgQueryNormalizeResult result = pg_query_normalize(input); if (result.error) { - char* error_msg = safe_strdup(result.error->message); + char* error_msg = error_result(result.error->message); pg_query_free_normalize_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + return error_msg; } char* normalized = safe_strdup(result.normalized_query); pg_query_free_normalize_result(result); - if (!normalized) { - return safe_strdup("Memory allocation failed"); - } - return normalized; } @@ -283,7 +288,7 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi } char* json = safe_malloc(estimated_size); if (!json) { - return safe_strdup("{\"version\":0,\"tokens\":[]}"); + return NULL; } // Start building JSON @@ -297,7 +302,10 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi if (token_length < 0) token_length = 0; // Safety check char* token_text = safe_malloc(token_length + 1); - if (!token_text) continue; + if (!token_text) { + free(json); + return NULL; + } if (token_length > 0) { strncpy(token_text, &original_sql[token->start], token_length); @@ -308,7 +316,8 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi char* escaped_text = safe_malloc(token_length * 2 + 1); if (!escaped_text) { free(token_text); - continue; + free(json); + return NULL; } int escaped_pos = 0; @@ -366,15 +375,15 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi EMSCRIPTEN_KEEPALIVE char* wasm_scan(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return error_result("Invalid input: query cannot be null or empty"); } PgQueryScanResult result = pg_query_scan(input); if (result.error) { - char* error_msg = safe_strdup(result.error->message); + char* error_msg = error_result(result.error->message); pg_query_free_scan_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + return error_msg; } // Unpack protobuf data @@ -383,7 +392,7 @@ char* wasm_scan(const char* input) { if (!scan_result) { pg_query_free_scan_result(result); - return safe_strdup("Failed to unpack scan result"); + return NULL; } // Convert to JSON @@ -393,7 +402,7 @@ char* wasm_scan(const char* input) { pg_query__scan_result__free_unpacked(scan_result, NULL); pg_query_free_scan_result(result); - return json_result ? json_result : safe_strdup("{\"version\":0,\"tokens\":[]}"); + return json_result; } EMSCRIPTEN_KEEPALIVE diff --git a/versions/18/src/index.ts b/versions/18/src/index.ts index 56c93d61..d1645a0e 100644 --- a/versions/18/src/index.ts +++ b/versions/18/src/index.ts @@ -251,6 +251,19 @@ function ptrToString(ptr: number): string { return wasmModule.UTF8ToString(ptr); } +// fingerprint, normalize and scan return "ERROR: " on failure and a +// null pointer when the WASM heap is exhausted. +function resultOrThrow(resultPtr: number): string { + if (!resultPtr) { + throw new Error('libpg_query returned no result: out of WASM memory (heap limit is WASM_MAXIMUM_MEMORY, 1 GiB); pass large inputs in smaller pieces'); + } + const resultStr = ptrToString(resultPtr); + if (resultStr.startsWith('ERROR: ')) { + throw new Error(resultStr.slice('ERROR: '.length)); + } + return resultStr; +} + export const parse = awaitInit(async (query: string): Promise => { // Input validation if (query === null || query === undefined) { @@ -345,11 +358,7 @@ export const fingerprint = awaitInit(async (query: string): Promise => { try { resultPtr = callWasm(() => wasmModule._wasm_fingerprint(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -369,11 +378,7 @@ export const normalize = awaitInit(async (query: string): Promise => { try { resultPtr = callWasm(() => wasmModule._wasm_normalize_query(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -491,11 +496,7 @@ export function fingerprintSync(query: string): string { try { resultPtr = callWasm(() => wasmModule._wasm_fingerprint(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -518,11 +519,7 @@ export function normalizeSync(query: string): string { try { resultPtr = callWasm(() => wasmModule._wasm_normalize_query(queryPtr)); - const resultStr = ptrToString(resultPtr); - - if (resultStr.startsWith('syntax error') || resultStr.startsWith('deparse error') || resultStr.startsWith('ERROR')) { - throw new Error(resultStr); - } + const resultStr = resultOrThrow(resultPtr); return resultStr; } finally { @@ -542,11 +539,7 @@ export const scan = awaitInit(async (query: string): Promise => { try { 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); - } + const resultStr = resultOrThrow(resultPtr); return JSON.parse(resultStr); } finally { @@ -569,11 +562,7 @@ export function scanSync(query: string): ScanResult { try { 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); - } + const resultStr = resultOrThrow(resultPtr); return JSON.parse(resultStr); } finally { diff --git a/versions/18/src/wasm_wrapper.c b/versions/18/src/wasm_wrapper.c index 63ae5dab..023a450a 100644 --- a/versions/18/src/wasm_wrapper.c +++ b/versions/18/src/wasm_wrapper.c @@ -33,6 +33,15 @@ static void* safe_malloc(size_t size) { return ptr; } +// fingerprint, normalize and scan return errors as "ERROR: " so the +// caller can tell them from a result, and NULL when out of memory. +static char* error_result(const char* message) { + size_t len = strlen(message) + sizeof("ERROR: "); + char* result = safe_malloc(len); + if (result) snprintf(result, len, "ERROR: %s", message); + return result; +} + EMSCRIPTEN_KEEPALIVE char* wasm_parse_query(const char* input) { if (!validate_input(input)) { @@ -114,15 +123,15 @@ char* wasm_parse_plpgsql(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_fingerprint(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return error_result("Invalid input: query cannot be null or empty"); } PgQueryFingerprintResult result = pg_query_fingerprint(input); if (result.error) { - char* error_msg = safe_strdup(result.error->message); + char* error_msg = error_result(result.error->message); pg_query_free_fingerprint_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + return error_msg; } char* fingerprint_str = safe_strdup(result.fingerprint_str); @@ -133,24 +142,20 @@ char* wasm_fingerprint(const char* input) { EMSCRIPTEN_KEEPALIVE char* wasm_normalize_query(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return error_result("Invalid input: query cannot be null or empty"); } PgQueryNormalizeResult result = pg_query_normalize(input); if (result.error) { - char* error_msg = safe_strdup(result.error->message); + char* error_msg = error_result(result.error->message); pg_query_free_normalize_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + return error_msg; } char* normalized = safe_strdup(result.normalized_query); pg_query_free_normalize_result(result); - if (!normalized) { - return safe_strdup("Memory allocation failed"); - } - return normalized; } @@ -290,7 +295,7 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi } char* json = safe_malloc(estimated_size); if (!json) { - return safe_strdup("{\"version\":0,\"tokens\":[]}"); + return NULL; } // Start building JSON @@ -304,7 +309,10 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi if (token_length < 0) token_length = 0; // Safety check char* token_text = safe_malloc(token_length + 1); - if (!token_text) continue; + if (!token_text) { + free(json); + return NULL; + } if (token_length > 0) { strncpy(token_text, &original_sql[token->start], token_length); @@ -315,7 +323,8 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi char* escaped_text = safe_malloc(token_length * 2 + 1); if (!escaped_text) { free(token_text); - continue; + free(json); + return NULL; } int escaped_pos = 0; @@ -373,15 +382,15 @@ static char* build_scan_json(PgQuery__ScanResult *scan_result, const char* origi EMSCRIPTEN_KEEPALIVE char* wasm_scan(const char* input) { if (!validate_input(input)) { - return safe_strdup("Invalid input: query cannot be null or empty"); + return error_result("Invalid input: query cannot be null or empty"); } PgQueryScanResult result = pg_query_scan(input); if (result.error) { - char* error_msg = safe_strdup(result.error->message); + char* error_msg = error_result(result.error->message); pg_query_free_scan_result(result); - return error_msg ? error_msg : safe_strdup("Memory allocation failed"); + return error_msg; } // Unpack protobuf data @@ -390,7 +399,7 @@ char* wasm_scan(const char* input) { if (!scan_result) { pg_query_free_scan_result(result); - return safe_strdup("Failed to unpack scan result"); + return NULL; } // Convert to JSON @@ -400,7 +409,7 @@ char* wasm_scan(const char* input) { pg_query__scan_result__free_unpacked(scan_result, NULL); pg_query_free_scan_result(result); - return json_result ? json_result : safe_strdup("{\"version\":0,\"tokens\":[]}"); + return json_result; } EMSCRIPTEN_KEEPALIVE diff --git a/versions/18/test/fingerprint.test.js b/versions/18/test/fingerprint.test.js index 07171c82..44d3b33b 100644 --- a/versions/18/test/fingerprint.test.js +++ b/versions/18/test/fingerprint.test.js @@ -40,6 +40,13 @@ describe("Query Fingerprinting", () => { it("should fail on invalid queries", () => { assert.throws(() => query.fingerprintSync("NOT A QUERY"), Error); }); + + // Lexer errors and empty input used to come back as the fingerprint. + it("should throw lexer errors and empty input instead of returning them", async () => { + assert.throws(() => query.fingerprintSync("SELECT 'abc"), /^Error: unterminated quoted string/); + await assert.rejects(query.fingerprint("SELECT 1e"), /^Error: trailing junk after numeric literal/); + assert.throws(() => query.fingerprintSync(""), /^Error: Invalid input/); + }); }); describe("Async Fingerprinting", () => { diff --git a/versions/18/test/normalize.test.js b/versions/18/test/normalize.test.js index 14f0a431..ccc20a73 100644 --- a/versions/18/test/normalize.test.js +++ b/versions/18/test/normalize.test.js @@ -42,6 +42,13 @@ describe("Query Normalization", () => { it("should fail on invalid queries", () => { assert.throws(() => query.normalizeSync("NOT A QUERY"), Error); }); + + // Lexer errors and empty input used to come back as the normalized query. + it("should throw lexer errors and empty input instead of returning them", async () => { + assert.throws(() => query.normalizeSync("SELECT 'abc"), /^Error: unterminated quoted string/); + await assert.rejects(query.normalize('SELECT "x'), /^Error: unterminated quoted identifier/); + assert.throws(() => query.normalizeSync(""), /^Error: Invalid input/); + }); }); describe("Async Normalization", () => { diff --git a/versions/18/test/scan.test.js b/versions/18/test/scan.test.js index bd927874..b10e5b4c 100644 --- a/versions/18/test/scan.test.js +++ b/versions/18/test/scan.test.js @@ -226,6 +226,22 @@ describe("Query Scanning", () => { assert.ok(result1.version > 0); }); + // These used to surface as a JSON SyntaxError on the error message. + it("should throw lexer errors as an Error with the lexer's message", async () => { + assert.throws(() => query.scanSync("SELECT 'abc"), /^Error: unterminated quoted string/); + await assert.rejects(query.scan("SELECT $$x"), /^Error: unterminated dollar-quoted string/); + assert.throws(() => query.scanSync(""), /^Error: Invalid input/); + }); + + // About 11 MB: the token JSON no longer fits in the 1 GiB heap. This used to + // return { version: 0, tokens: [] } instead of throwing. + it("should throw when the token JSON does not fit in WASM memory", async () => { + const sql = "SELECT a, b FROM t WHERE c = 1;\n".repeat(360000); + assert.throws(() => query.scanSync(sql), /out of WASM memory/); + await assert.rejects(query.scan(sql), /out of WASM memory/); + assert.equal(query.scanSync("SELECT 1").tokens.length, 2); + }); + it("should handle multi-line dollar-quoted strings without JSON errors", () => { // Without the fix, scanSync throws: // "Bad control character in string literal"