From cb9edf4305458a5de5a7c84f0fc041e971484fd2 Mon Sep 17 00:00:00 2001 From: rgb-vgx Date: Sun, 4 Oct 2026 01:32:25 +0700 Subject: [PATCH] fix(group): decode Go string escapes in HTTP route literals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unquoteLiteral only strips surrounding quotes, so an interpreted Go string kept its escape sequences verbatim: r.Group("/api\x2fv1") was recorded as /api\x2fv1 although the runtime registers /api/v1. The ingestion extractor already decodes Go strings fully (strconv.Unquote semantics: simple/hex/octal/\u/\U escapes, raw-string carriage returns, fail-closed on undecodable bytes), so the graph and the source-scan plugin disagreed on the same route's contract id — both survive the merge as a wrong-path duplicate. Extract that decoding into route-extractors/go-shared.ts and use it from both layers: go-gin-echo.ts imports it unchanged, and every string capture in the group Go plugin (group prefixes, framework paths, HandleFunc, http client, NewRequest, resty) now goes through it. Raw (backtick) strings keep Go semantics: no escape processing. Covers it with two tests: hex escapes decoded in a prefix and a route path, and escapes left literal inside a raw-string prefix. Co-Authored-By: Claude Code --- .../core/group/extractors/http-patterns/go.ts | 32 ++++---- .../ingestion/route-extractors/go-gin-echo.ts | 54 +------------ .../ingestion/route-extractors/go-shared.ts | 75 +++++++++++++++++++ .../unit/group/go-gin-route-groups.test.ts | 25 +++++++ 4 files changed, 115 insertions(+), 71 deletions(-) create mode 100644 gitnexus/src/core/ingestion/route-extractors/go-shared.ts diff --git a/gitnexus/src/core/group/extractors/http-patterns/go.ts b/gitnexus/src/core/group/extractors/http-patterns/go.ts index 5944b0868..86412fc77 100644 --- a/gitnexus/src/core/group/extractors/http-patterns/go.ts +++ b/gitnexus/src/core/group/extractors/http-patterns/go.ts @@ -1,9 +1,9 @@ import type Parser from 'tree-sitter'; import Go from 'tree-sitter-go'; +import { stringLiteral } from '../../../ingestion/route-extractors/go-shared.js'; import { compilePatterns, runCompiledPatterns, - unquoteLiteral, type LanguagePatterns, } from '../tree-sitter-scanner.js'; import type { HttpDetection, HttpLanguagePlugin } from './types.js'; @@ -78,17 +78,13 @@ function asGroupCall( } const parent = fn.childForFieldName('operand'); const first = node.childForFieldName('arguments')?.namedChildren[0]; - // Both string-literal forms are valid Go: "…" and `…`. unquoteLiteral - // strips either, and a non-literal argument (a variable, concatenation) - // still contributes no prefix. - if ( - !parent || - (first?.type !== 'interpreted_string_literal' && first?.type !== 'raw_string_literal') - ) { - return null; - } - const prefix = unquoteLiteral(first.text); - return prefix === null ? null : { parent, prefix }; + // Only a string literal carries a prefix, and it must decode to the text + // the runtime registers: stringLiteral applies Go unescaping (both `"…"` + // with escapes and raw `` `…` `` strings). A non-literal argument (a + // variable, concatenation) or an undecodable string contributes no prefix. + const prefix = first ? stringLiteral(first) : null; + if (!parent || prefix === null) return null; + return { parent, prefix }; } /** The expression `name` is assigned by `stmt` (`:=`, `=`, or `var`), if any. */ @@ -305,7 +301,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const handlerNode = match.captures.handler; const receiverNode = match.captures.receiver; if (!methodNode || !pathNode) continue; - const literalPath = unquoteLiteral(pathNode.text); + const literalPath = stringLiteral(pathNode); if (literalPath === null) continue; const path = receiverNode ? joinRoutePath(groupPrefix(receiverNode), literalPath) @@ -338,7 +334,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const pathNode = match.captures.path; const handlerNode = match.captures.handler; if (!pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; // Inline `func(){…}` handler → resolve by containment (see go-framework // note above); a named handler resolves by name. @@ -361,7 +357,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { if (!fnNode || !pathNode) continue; const httpMethod = HTTP_CLIENT_METHOD_TO_HTTP[fnNode.text]; if (!httpMethod) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', @@ -379,8 +375,8 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const method = unquoteLiteral(methodNode.text); - const path = unquoteLiteral(pathNode.text); + const method = stringLiteral(methodNode); + const path = stringLiteral(pathNode); if (method === null || path === null) continue; out.push({ role: 'consumer', @@ -398,7 +394,7 @@ export const GO_HTTP_PLUGIN: HttpLanguagePlugin = { const methodNode = match.captures.http_method; const pathNode = match.captures.path; if (!methodNode || !pathNode) continue; - const path = unquoteLiteral(pathNode.text); + const path = stringLiteral(pathNode); if (path === null) continue; out.push({ role: 'consumer', diff --git a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts index f7cdd1c64..e77b1b82b 100644 --- a/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts +++ b/gitnexus/src/core/ingestion/route-extractors/go-gin-echo.ts @@ -26,6 +26,7 @@ import type Parser from 'tree-sitter'; import { goImportPackageName } from '../languages/go/import-package-name.js'; import { GoRouteBindings, type GoRouteBinding } from '../languages/go/route-bindings.js'; +import { stringLiteral } from './go-shared.js'; import { normalizeExtractedRoutePath } from './route-path.js'; import type { SyntaxNode } from 'tree-sitter'; import type { ExtractedDecoratorRoute, RouteHandlerReceiver } from '../workers/parse-worker.js'; @@ -58,59 +59,6 @@ interface Framework { const FUNCTION_TYPE_LIST = ['function_declaration', 'method_declaration', 'func_literal']; const FUNCTION_TYPES: ReadonlySet = new Set(FUNCTION_TYPE_LIST); -function stringLiteral(node: SyntaxNode | null | undefined): string | null { - if (!node || node.hasError) return null; - const body = node.text.slice(1, -1); - // Go discards carriage returns in raw strings, including CRLF source files. - if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); - if (node.type !== 'interpreted_string_literal') return null; - if (!body.includes('\\')) return body; - - const simple: Readonly> = { - a: '\x07', - b: '\b', - f: '\f', - n: '\n', - r: '\r', - t: '\t', - v: '\v', - '\\': '\\', - '"': '"', - }; - const chunks: Buffer[] = []; - const tokens = - /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; - let consumed = 0; - for (const match of body.matchAll(tokens)) { - if (match.index !== consumed) return null; - const token = match[0]; - consumed += token.length; - if (!token.startsWith('\\')) { - chunks.push(Buffer.from(token)); - } else if (simple[token[1]] !== undefined) { - chunks.push(Buffer.from(simple[token[1]])); - } else { - const octal = /[0-7]/.test(token[1]); - const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); - if (octal || token[1] === 'x') { - // Octal and hex escapes encode bytes, not Unicode code points. - if (value > 255) return null; - chunks.push(Buffer.from([value])); - } else { - if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; - chunks.push(Buffer.from(String.fromCodePoint(value))); - } - } - } - if (consumed !== body.length) return null; - try { - // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. - return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); - } catch { - return null; - } -} - /** The framework this file routes with, when exactly one is imported. */ function readImports(root: SyntaxNode): { readonly framework: Framework | null; diff --git a/gitnexus/src/core/ingestion/route-extractors/go-shared.ts b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts new file mode 100644 index 000000000..a2d13faef --- /dev/null +++ b/gitnexus/src/core/ingestion/route-extractors/go-shared.ts @@ -0,0 +1,75 @@ +import type { SyntaxNode } from 'tree-sitter'; + +/** + * Go string-literal decoding shared by the Go route extractors on both + * layers: the ingestion extractor (`go-gin-echo.ts`, Strategy A) and the + * group-mode HTTP plugin (`group/extractors/http-patterns/go.ts`, + * Strategy B). Both sides must produce the SAME text for a route literal, + * or the same route lands under two different contract ids that never + * collide in the merge — a wrong-path duplicate that survives alongside + * the graph's correct entry. + * + * Go's semantics, per `strconv.Unquote`: + * - interpreted (`"…"`) strings decode escapes (`\n`, `\x2f`, `ሴ`, + * `\101`, …); hex and octal escapes encode BYTES, not code points; + * - raw (`` `…` ``) strings have no escapes (a backslash is literal), + * and Go discards carriage returns in them, including CRLF source files; + * - a string that cannot be decoded to valid UTF-8 text (invalid escape, + * non-UTF-8 bytes) returns null — callers decline instead of guessing, + * since a URL cannot carry those bytes losslessly. + * + * Nodes that are not string literals (an identifier prefix, a rune + * literal, an errored subtree) also return null. + */ +export function stringLiteral(node: SyntaxNode | null | undefined): string | null { + if (!node || node.hasError) return null; + const body = node.text.slice(1, -1); + // Go discards carriage returns in raw strings, including CRLF source files. + if (node.type === 'raw_string_literal') return body.replace(/\r/g, ''); + if (node.type !== 'interpreted_string_literal') return null; + if (!body.includes('\\')) return body; + + const simple: Readonly> = { + a: '\x07', + b: '\b', + f: '\f', + n: '\n', + r: '\r', + t: '\t', + v: '\v', + '\\': '\\', + '"': '"', + }; + const chunks: Buffer[] = []; + const tokens = + /\\(?:[abfnrtv\\"]|[0-7]{3}|x[\da-fA-F]{2}|u[\da-fA-F]{4}|U[\da-fA-F]{8})|[^\\"\n]+/g; + let consumed = 0; + for (const match of body.matchAll(tokens)) { + if (match.index !== consumed) return null; + const token = match[0]; + consumed += token.length; + if (!token.startsWith('\\')) { + chunks.push(Buffer.from(token)); + } else if (simple[token[1]] !== undefined) { + chunks.push(Buffer.from(simple[token[1]])); + } else { + const octal = /[0-7]/.test(token[1]); + const value = Number.parseInt(token.slice(octal ? 1 : 2), octal ? 8 : 16); + if (octal || token[1] === 'x') { + // Octal and hex escapes encode bytes, not Unicode code points. + if (value > 255) return null; + chunks.push(Buffer.from([value])); + } else { + if (value > 0x10ffff || (value >= 0xd800 && value <= 0xdfff)) return null; + chunks.push(Buffer.from(String.fromCodePoint(value))); + } + } + } + if (consumed !== body.length) return null; + try { + // Arbitrary non-UTF-8 Go byte strings cannot be represented losslessly in a URL. + return new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks)); + } catch { + return null; + } +} diff --git a/gitnexus/test/unit/group/go-gin-route-groups.test.ts b/gitnexus/test/unit/group/go-gin-route-groups.test.ts index 40030970d..b751bdaab 100644 --- a/gitnexus/test/unit/group/go-gin-route-groups.test.ts +++ b/gitnexus/test/unit/group/go-gin-route-groups.test.ts @@ -371,6 +371,31 @@ func routes(r *gin.Engine) { ).toEqual([{ method: 'GET', path: '/api/x', name: 'X' }]); }); + it('decodes Go string escapes in group prefixes and route paths', () => { + // "/api\x2fv1" and "/health\x2fcheck" are `/api/v1` and `/health/check` + // once Go processes the escapes — the id must match the registered URL. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group("/api\\x2fv1") + g.GET("/health\\x2fcheck", h.H) +} +`), + ).toEqual([{ method: 'GET', path: '/api/v1/health/check', name: 'H' }]); + }); + + it('leaves escapes literal inside a raw-string (backtick) prefix', () => { + // Go raw strings process no escapes: the prefix is `/raw\x2fy`, backslash included. + expect( + providers(`package main +func routes(r *gin.Engine) { + g := r.Group(\`/raw\\x2fy\`) + g.GET("/z", h.Z) +} +`), + ).toEqual([{ method: 'GET', path: '/raw\\x2fy/z', name: 'Z' }]); + }); + it('applies the same group logic to echo', () => { expect( providers(`package main