mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
fix(group): decode Go string escapes in HTTP route literals
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 <noreply@anthropic.com>
This commit is contained in:
parent
d30e4c3ef6
commit
cb9edf4305
4 changed files with 115 additions and 71 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string> = 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<Record<string, string>> = {
|
||||
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;
|
||||
|
|
|
|||
75
gitnexus/src/core/ingestion/route-extractors/go-shared.ts
Normal file
75
gitnexus/src/core/ingestion/route-extractors/go-shared.ts
Normal file
|
|
@ -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<Record<string, string>> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue