From 00f9d0eb6eaa0440191f6cf2dee5a29e54bab2b6 Mon Sep 17 00:00:00 2001 From: Dave Jong Date: Mon, 28 Sep 2026 16:12:32 +0200 Subject: [PATCH] Resolve request bindings and helpers by scope, and recognise only served routes Taint roots and same-file helpers are now keyed by the declaration an identifier resolves to, not by its name. A block-scoped binding, a callback parameter or an inner function's own request parameter that shares a name with a request binding is a different variable and no longer proves a flow. A binding that is assigned again after its declaration still proves reachability, but not an exact value. A route registration needs a URL path as its first argument, server actions no longer take a route from their file location, and Nuxt file routes come only from server/api and server/routes. Co-Authored-By: Claude Opus 5.5 --- src/map/ast.ts | 23 ++++ src/map/entries.ts | 12 +-- src/map/extract.ts | 4 +- src/map/flows.ts | 132 ++++++++++++++++++----- src/map/inputs.ts | 33 +++--- src/map/module-graph.ts | 2 +- src/map/routes.ts | 27 ++++- src/map/scope.ts | 116 ++++++++++++++++++++ src/map/sinks.ts | 68 +++++++++--- tests/map/binding-scope.test.ts | 161 ++++++++++++++++++++++++++++ tests/map/route-recognition.test.ts | 76 +++++++++++++ 11 files changed, 585 insertions(+), 69 deletions(-) create mode 100644 src/map/scope.ts create mode 100644 tests/map/binding-scope.test.ts create mode 100644 tests/map/route-recognition.test.ts diff --git a/src/map/ast.ts b/src/map/ast.ts index 75cba197..469aee25 100644 --- a/src/map/ast.ts +++ b/src/map/ast.ts @@ -17,6 +17,18 @@ export function rootIdentifier(node: any, ts: TsModule): string | undefined { return undefined; } +/** The leftmost identifier NODE of a chain, for callers that resolve it to its declaration. */ +export function rootIdentifierNode(node: any, ts: TsModule): any | undefined { + let cur = node; + while (cur) { + if (ts.isIdentifier(cur)) return cur; + if (ts.isPropertyAccessExpression(cur) || ts.isElementAccessExpression(cur) || ts.isCallExpression(cur) || ts.isNewExpression(cur) || ts.isNonNullExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isAwaitExpression(cur)) { + cur = cur.expression; + } else return undefined; + } + return undefined; +} + // Source span of a node: the auditable coordinate, AND the sink's identity for flow analysis (a line is // not an identity — two sinks can share one, and an enclosing statement can hold unrelated expressions). export function spanOf(node: any): { line?: number; start?: number; end?: number } { @@ -140,6 +152,17 @@ export function opCallOf(propAccess: any, ts: TsModule): any { return p && ts.isCallExpression(p) && p.expression === propAccess ? p : propAccess; } +/** The callee identifiers of plain calls (`run(x)`) in a subtree, for resolving each to its declaration. */ +export function localCallIdentifiers(node: any, ts: TsModule): any[] { + const out: any[] = []; + const visit = (n: any) => { + if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) out.push(n.expression); + ts.forEachChild(n, visit); + }; + visit(node); + return out; +} + export function localCalls(node: any, ts: TsModule): string[] { const names: string[] = []; const visit = (n: any) => { diff --git a/src/map/entries.ts b/src/map/entries.ts index 7d2b945e..a9778c8d 100644 --- a/src/map/entries.ts +++ b/src/map/entries.ts @@ -1,17 +1,17 @@ import type { Endpoint, Sink, TsModule } from './types.js'; import { hasExport, isFnLike, methodFromObjectArg, spanOf, unwindChain } from './ast.js'; import type { Bindings } from './bindings.js'; -import { functionNameFromPath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; +import { functionNameFromPath, isRoutePath, ROUTE_REGISTER, routeFromChain, routeObject } from './routes.js'; import { withCoordinates } from './coordinates.js'; import { inputsFromHandler, inputsFromValidator } from './inputs.js'; -import { sinksFrom, type SinkContext } from './sinks.js'; +import { sinksFrom, type LocalSinks, type SinkContext } from './sinks.js'; import { linkedFlows } from './flows.js'; import { collectInvocations } from './invocations.js'; const HTTP_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']); // --- entry-point recognizers ----------------------------------------------- -export function extractFromFile(sf: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx: SinkContext): Omit[] { +export function extractFromFile(sf: any, ts: TsModule, localSinks: LocalSinks, bindings: Bindings, ctx: SinkContext): Omit[] { const out: Omit[] = []; const isServerActionsFile = fileHasUseServer(sf, ts); @@ -88,7 +88,7 @@ export function extractFromFile(sf: any, ts: TsModule, localSinks: Map, + localSinks: LocalSinks, bindings: Bindings, ctx: SinkContext, extra: { method?: string; route?: string; line?: number; start?: number; end?: number } = {}, diff --git a/src/map/extract.ts b/src/map/extract.ts index fd83f7a7..c9414db1 100644 --- a/src/map/extract.ts +++ b/src/map/extract.ts @@ -114,7 +114,9 @@ export async function extractInputMap(cwd: string, ts: TsModule, options: Extrac const fn = functionNameFromPath(relFile); if (fn) ep.route = '/' + fn; // how the platform invokes it (…/functions/v1/) } - if (ep.route === undefined && (ep.entryKind === 'route-handler' || ep.entryKind === 'server-action')) { + // A server action has no URL of its own: it is posted to whichever page renders it, so its file + // location names no route, and a route scope derived from it would never match its traffic. + if (ep.route === undefined && ep.entryKind === 'route-handler') { const derived = routeFromFilePath(relFile); if (derived.route) { ep.route = derived.route; diff --git a/src/map/flows.ts b/src/map/flows.ts index f97e9083..d3f70298 100644 --- a/src/map/flows.ts +++ b/src/map/flows.ts @@ -1,5 +1,6 @@ import type { AddressSpace, ApiInvocation, ArgumentRole, DependencyInputFlow, Flow, InputField, Limitation, Sink, TsModule } from './types.js'; -import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifier } from './ast.js'; +import { bindingKey, calleeName, isValueRead, lineOf, rootIdentifierNode } from './ast.js'; +import { declarationOf } from './scope.js'; import { REQ_SOURCES } from './inputs.js'; import { addressSpaceOf } from './coordinates.js'; import { argumentRoleOf, CANDIDATE_FAMILIES } from './sinks.js'; @@ -22,6 +23,77 @@ interface Root { * and reading them as namespaces would move a body field into another address space. */ request?: boolean; + /** + * The binding, or one it was derived from, is assigned again after its declaration. What reaches a + * sink through it may be the request value or whatever replaced it, so a read through it is evidence + * of reachability but never of an exact, untransformed value. + */ + reassigned?: boolean; +} + +/** + * Tainted bindings keyed by the declaration an occurrence resolves to, never by its name: a block-scoped + * or callback binding that happens to share a request binding's name is a different variable. + */ +class Roots { + private readonly byDeclaration = new Map(); + + constructor(private readonly ts: TsModule) {} + + add(declaration: any, root: Root): void { + if (!this.byDeclaration.has(declaration)) this.byDeclaration.set(declaration, root); + } + + /** The root an identifier occurrence refers to, when its binding is tainted. */ + get(id: any): Root | undefined { + const declaration = declarationOf(id, this.ts); + + return declaration === undefined ? undefined : this.byDeclaration.get(declaration); + } +} + +/** Declarations whose binding is the target of an assignment, an update or a `for…of`/`for…in` head. */ +function reassignedDeclarations(body: any, ts: TsModule): Set { + const out = new Set(); + const target = (n: any): void => { + if (!n) return; + if (ts.isParenthesizedExpression(n)) return target(n.expression); + if (ts.isIdentifier(n)) { + const declaration = declarationOf(n, ts); + if (declaration !== undefined) out.add(declaration); + return; + } + // Destructuring assignment: `({ cmd } = other)`, `[cmd] = list`. + if (ts.isObjectLiteralExpression(n)) { + for (const p of n.properties) { + if (ts.isShorthandPropertyAssignment(p)) target(p.name); + else if (ts.isPropertyAssignment(p)) target(p.initializer); + else if (ts.isSpreadAssignment(p)) target(p.expression); + } + return; + } + if (ts.isArrayLiteralExpression(n)) { + for (const e of n.elements) target(ts.isSpreadElement(e) ? e.expression : e); + return; + } + if (ts.isBinaryExpression(n) && n.operatorToken.kind === ts.SyntaxKind.EqualsToken) target(n.left); + }; + const visit = (n: any) => { + if (!n) return; + if (ts.isBinaryExpression(n) + && n.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && n.operatorToken.kind <= ts.SyntaxKind.LastAssignment) { + target(n.left); + } + if ((ts.isPrefixUnaryExpression(n) || ts.isPostfixUnaryExpression(n)) + && (n.operator === ts.SyntaxKind.PlusPlusToken || n.operator === ts.SyntaxKind.MinusMinusToken)) { + target(n.operand); + } + if ((ts.isForOfStatement(n) || ts.isForInStatement(n)) && !ts.isVariableDeclarationList(n.initializer)) target(n.initializer); + ts.forEachChild(n, visit); + }; + visit(body); + + return out; } /** Request namespaces whose `.get('')` reads the field ``. */ @@ -87,21 +159,24 @@ function linkFlows( // has none — its next segment decides (`req.query.x` vs `req.body.x`) — but `({ query: q })` fixes `q` // in `get` for good. Without this the space was dropped along with the namespace segment, so a read of // `query.id` was indistinguishable from a read of `body.id` and could match either input. - const rootPath = new Map(); - const addRoot = (name: string, path: string, space?: AddressSpace, accessor = false, request = false) => { - if (!rootPath.has(name)) rootPath.set(name, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}) }); + const rootPath = new Roots(ts); + // A binding assigned again after its declaration may no longer hold what it was declared with. + const reassigned = reassignedDeclarations(bodyNode, ts); + const addRoot = (declaration: any, path: string, space?: AddressSpace, accessor = false, request = false, inherited = false) => { + const changed = inherited || reassigned.has(declaration); + rootPath.add(declaration, { path, space, ...(accessor ? { accessor } : {}), ...(request ? { request } : {}), ...(changed ? { reassigned: true } : {}) }); }; for (const [index, p] of (params ?? []).entries()) { if (!p?.name) continue; // The handler's first parameter is its request; later ones (`res`, a route context) are not. - if (ts.isIdentifier(p.name)) addRoot(p.name.text, '', undefined, false, index === 0); + if (ts.isIdentifier(p.name)) addRoot(p.name, '', undefined, false, index === 0); else if (ts.isObjectBindingPattern(p.name)) { for (const el of p.name.elements) { if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; const key = bindingKey(el, ts); // A destructured request source (`{ body }`) is a container: its members ARE the paths. const container = key !== undefined && CONTAINER_KEYS.has(key); - addRoot(el.name.text, container ? '' : key ?? el.name.text, container ? spaceOfKey(key) : undefined, container && ACCESSOR_NAMESPACES.has(key!)); + addRoot(el.name, container ? '' : key ?? el.name.text, container ? spaceOfKey(key) : undefined, container && ACCESSOR_NAMESPACES.has(key!)); } } } @@ -116,9 +191,9 @@ function linkFlows( if (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) { const m = cur.expression.name.text; if (['json', 'formData', 'text'].includes(m)) { - const root = rootIdentifier(cur.expression.expression, ts); + const root = rootIdentifierNode(cur.expression.expression, ts); // A body read: whatever the field names turn out to be, they are addressed in `post`. - if (!root || !rootPath.has(root)) return undefined; + if (!root || !rootPath.get(root)) return undefined; return m === 'formData' ? { path: '', space: 'post', accessor: true } : { path: '', space: 'post' }; } @@ -137,7 +212,7 @@ function linkFlows( if (ts.isVariableDeclaration(n) && n.initializer) { const base = requestReadPath(n.initializer); if (base !== undefined) { - if (ts.isIdentifier(n.name)) addRoot(n.name.text, base.path, base.space, base.accessor === true); + if (ts.isIdentifier(n.name)) addRoot(n.name, base.path, base.space, base.accessor === true, false, base.reassigned === true); else if (ts.isObjectBindingPattern(n.name)) { for (const el of n.name.elements) { if (!ts.isBindingElement(el) || !ts.isIdentifier(el.name)) continue; @@ -148,7 +223,7 @@ function linkFlows( const container = base.path === '' && key !== undefined && CONTAINER_KEYS.has(key) && (namespace || !REQ_SOURCES.includes(key)); // `const { query: q } = req` — the binding KEY names the space when the base has none yet. const space = base.space ?? (namespace ? spaceOfKey(key) : undefined); - addRoot(el.name.text, container ? '' : join2(base.path, key ?? el.name.text), space, namespace && ACCESSOR_NAMESPACES.has(key!)); + addRoot(el.name, container ? '' : join2(base.path, key ?? el.name.text), space, namespace && ACCESSOR_NAMESPACES.has(key!), false, base.reassigned === true); } } } @@ -200,7 +275,7 @@ function linkFlows( const whole = pathFromTainted(args[i], ts, rootPath) ?? accessorRead(args[i], ts, rootPath); for (const read of taintedReadPaths(args[i], ts, rootPath)) { const key = `${read.space ?? '*'}:${read.path}`; - const exact = whole !== undefined && whole.path === read.path && whole.space === read.space; + const exact = whole !== undefined && whole.path === read.path && whole.space === read.space && whole.reassigned !== true; const entry = reads.get(key) ?? { read, roles: new Set(), exact: false }; entry.roles.add(role); entry.exact = entry.exact || exact; @@ -336,7 +411,7 @@ function linkFlows( kind: invocation.kind, resolution: invocation.resolution, argumentIndex, - argumentUse: whole?.path === read.path && whole.space === read.space ? 'direct' : 'within-expression', + argumentUse: whole?.path === read.path && whole.space === read.space && whole.reassigned !== true ? 'direct' : 'within-expression', line: site.line, start: site.start, end: site.end, @@ -409,7 +484,7 @@ function fluentChainCalls(call: any, ts: TsModule): any[] { * never be mistaken for the distinct input `billing.email`. Array indices normalize to `[]`. * Property KEYS, member names and binding names are not reads. */ -function taintedReadPaths(node: any, ts: TsModule, rootPath: Map, includeDeferredBodies = true): Root[] { +function taintedReadPaths(node: any, ts: TsModule, rootPath: Roots, includeDeferredBodies = true): Root[] { const out: Root[] = []; const seen = new Set(); const add = (r: Root) => { @@ -434,9 +509,9 @@ function taintedReadPaths(node: any, ts: TsModule, rootPath: Map, const read = pathFromTainted(n, ts, rootPath); if (read !== undefined) { add(read); return; } // the inner nodes are the path, not separate reads } - if (ts.isIdentifier(n) && rootPath.has(n.text) && isValueRead(n, ts)) { - const r = rootPath.get(n.text)!; - if (r.path) add({ path: normalizePath(r.path), space: r.space }); + const bound = ts.isIdentifier(n) && isValueRead(n, ts) ? rootPath.get(n) : undefined; + if (bound) { + if (bound.path) add({ path: normalizePath(bound.path), space: bound.space }); } ts.forEachChild(n, visit); }; @@ -451,7 +526,7 @@ function taintedReadPaths(node: any, ts: TsModule, rootPath: Map, * - `insert({ v: body[field] })` → the field is chosen at runtime; no coordinate can name it. * - `insert({ ...body })` → the whole payload reaches the sink; which field is unidentifiable. */ -function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): Limitation[] { +function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Roots): Limitation[] { const out: Limitation[] = []; const seen = new Set(); const add = (kind: Limitation['kind'], detail: string, n: any) => { @@ -467,16 +542,16 @@ function sinkArgumentLimitations(node: any, ts: TsModule, rootPath: Map): Root | undefined { +function accessorRead(node: any, ts: TsModule, rootPath: Roots): Root | undefined { let cur = node; while (cur && (ts.isAwaitExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; if (!cur || !ts.isCallExpression(cur) || !ts.isPropertyAccessExpression(cur.expression)) return undefined; @@ -508,12 +583,12 @@ function accessorRead(node: any, ts: TsModule, rootPath: Map): Roo * `request.cookies` on the request itself, or a binding that holds one (`({ cookies })`, a * `formData()` result). */ -function isAccessor(node: any, ts: TsModule, rootPath: Map): boolean { +function isAccessor(node: any, ts: TsModule, rootPath: Roots): boolean { let cur = node; while (cur && (ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; - if (ts.isIdentifier(cur)) return rootPath.get(cur.text)?.accessor === true; + if (ts.isIdentifier(cur)) return rootPath.get(cur)?.accessor === true; if (ts.isPropertyAccessExpression(cur) && ACCESSOR_NAMESPACES.has(cur.name.text) && ts.isIdentifier(cur.expression)) { - return rootPath.get(cur.expression.text)?.request === true; + return rootPath.get(cur.expression)?.request === true; } return false; @@ -523,7 +598,7 @@ function isAccessor(node: any, ts: TsModule, rootPath: Map): boole * Canonical path of a member/element access rooted in a tainted binding, or undefined if not tainted. * `trailing` is appended to the segments read off `node` — the field an accessor call names. */ -function pathFromTainted(node: any, ts: TsModule, rootPath: Map, trailing: string[] = []): Root | undefined { +function pathFromTainted(node: any, ts: TsModule, rootPath: Roots, trailing: string[] = []): Root | undefined { const segs: string[] = []; let cur = node; for (;;) { @@ -538,7 +613,7 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Map, t break; } if (!cur || !ts.isIdentifier(cur)) return undefined; - const base = rootPath.get(cur.text); + const base = rootPath.get(cur); if (base === undefined) return undefined; segs.push(...trailing); let space = base.space; @@ -556,6 +631,7 @@ function pathFromTainted(node: any, ts: TsModule, rootPath: Map, t // Read bare, the request is still the request — which is what lets `const { headers } = request` // destructure a namespace, and `const { headers } = await request.json()` not. const request = base.request === true && segs.length === 0 ? { request: true } : {}; + const reassigned = base.reassigned === true ? { reassigned: true } : {}; - return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request }; + return { path: normalizePath([base.path, ...segs].filter(Boolean).join('.')), space, ...request, ...reassigned }; } diff --git a/src/map/inputs.ts b/src/map/inputs.ts index 804a6501..d497553d 100644 --- a/src/map/inputs.ts +++ b/src/map/inputs.ts @@ -1,5 +1,6 @@ import type { FieldShape, InputField, InputSource, TsModule } from './types.js'; import { bindingKey, rootIdentifier } from './ast.js'; +import { declarationOf } from './scope.js'; import { npmPackageOf, type Bindings } from './bindings.js'; import { addressSpaceOf, inputIdOf, runtimeCoordinate } from './coordinates.js'; @@ -150,20 +151,24 @@ function requestMemberAccesses( out.set(name, list); }; const p0 = params?.[0]; - const reqName = p0 && ts.isIdentifier(p0.name) ? p0.name.text : undefined; + // The request binding itself, so an inner function's own parameter of the same name is not the request. + const reqDecl = p0 && ts.isIdentifier(p0.name) ? p0.name : undefined; + const isRequest = (e: any): boolean => reqDecl !== undefined && ts.isIdentifier(e) && declarationOf(e, ts) === reqDecl; // Identifiers that ARE a request-input object (destructured `({ body })` param, `await req.json()`), // mapped to the NAMESPACE each one came from. It has to be a map, not a set of names: with // `({ query: q })` the local is `q`, and matching the local against the literal 'query'/'params' // discards the namespace — which silently mis-addresses the input (`post.doc` for a query-string // field, and worse, a coordinate for a route param, which the resolver cannot address at all). - const sourceNames = new Map(); - const payloadNames = new Set(); - if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name.text); - if (p0 && !reqName && ts.isObjectBindingPattern(p0.name)) { + // Keyed by declaration, like the request itself: a same-named binding in another scope is not one. + const sourceNames = new Map(); + const payloadNames = new Set(); + const sourceOf = (e: any): InputSource | undefined => (ts.isIdentifier(e) ? sourceNames.get(declarationOf(e, ts)) : undefined); + if (opts.payloadParam && p0 && ts.isIdentifier(p0.name)) payloadNames.add(p0.name); + if (p0 && !reqDecl && ts.isObjectBindingPattern(p0.name)) { for (const el of p0.name.elements) { const key = bindingKey(el, ts); if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) { - sourceNames.set(el.name.text, namespaceSource(key)); + sourceNames.set(el.name, namespaceSource(key)); } } } @@ -172,16 +177,16 @@ function requestMemberAccesses( while (cur && (ts.isAwaitExpression(cur) || ts.isAsExpression(cur) || ts.isParenthesizedExpression(cur) || ts.isNonNullExpression(cur))) cur = cur.expression; return cur; }; - const isPayloadExpr = (e: any): boolean => ts.isIdentifier(e) && payloadNames.has(e.text); + const isPayloadExpr = (e: any): boolean => ts.isIdentifier(e) && payloadNames.has(declarationOf(e, ts)); const isReqSourceExpr = (e: any): boolean => isPayloadExpr(e) || - (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === reqName && REQ_SOURCES.includes(e.name.text)) || - (ts.isIdentifier(e) && sourceNames.has(e.text)); + (ts.isPropertyAccessExpression(e) && isRequest(e.expression) && REQ_SOURCES.includes(e.name.text)) || + sourceOf(e) !== undefined; const isBodyReadCall = (e: any): boolean => { const inner = unwrap(e); return Boolean(inner && ts.isCallExpression(inner) && ts.isPropertyAccessExpression(inner.expression) && ['json', 'formData'].includes(inner.expression.name.text) && - ts.isIdentifier(inner.expression.expression) && inner.expression.expression.text === reqName); + isRequest(inner.expression.expression)); }; // `request.headers.get` in `request.headers.get('x')` is a METHOD of the namespace, not a field of it. // Recording it would invent an input named `get` — a coordinate no request carries. @@ -213,14 +218,14 @@ function requestMemberAccesses( if (ts.isVariableDeclaration(n) && n.initializer) { const init = unwrap(n.initializer); // const b = await request.json() → b is a request-input object from here on. - if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name.text, bodyReadSource(n.initializer)); + if (ts.isIdentifier(n.name) && isBodyReadCall(n.initializer)) sourceNames.set(n.name, bodyReadSource(n.initializer)); // const { query: q } = req → the SAME namespace capture as a destructured handler param, just one // statement later. Without this the fields read off `q` are invisible: no coordinate is emitted // (so nothing is mis-addressed) but the surface goes unreported, which reads as "nothing here". - if (ts.isObjectBindingPattern(n.name) && ts.isIdentifier(init) && reqName && init.text === reqName) { + if (ts.isObjectBindingPattern(n.name) && isRequest(init)) { for (const el of n.name.elements) { const key = bindingKey(el, ts); - if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.set(el.name.text, namespaceSource(key)); + if (key && REQ_SOURCES.includes(key) && ts.isIdentifier(el.name)) sourceNames.set(el.name, namespaceSource(key)); } } // const { a, b } = | await request.json() @@ -249,7 +254,7 @@ function requestMemberAccesses( } // The recorded namespace, so an ALIAS resolves correctly (`({ query: q }) => q.id` → query). if (ts.isIdentifier(e)) { - const recorded = sourceNames.get(e.text); + const recorded = sourceOf(e); if (recorded) return recorded; } return 'body'; diff --git a/src/map/module-graph.ts b/src/map/module-graph.ts index 38540b25..a07acde4 100644 --- a/src/map/module-graph.ts +++ b/src/map/module-graph.ts @@ -26,7 +26,7 @@ export function createModuleGraph(ts: TsModule, opts: { cwd: string; boundary: s const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, guessScriptKind(ts, file)); const bindings = buildModuleBindings(sf, ts); // `bindings` is kept for `importedBinding`: the module's own view of what its exports came from. - entry = { fnSinks: collectLocalSinks(sf, ts, bindings), calleesOf: collectCallees(sf, ts), bindings }; + entry = { fnSinks: collectLocalSinks(sf, ts, bindings).byName, calleesOf: collectCallees(sf, ts), bindings }; } catch { entry = null; // fail-open: an unreadable dependency must not break the map } diff --git a/src/map/routes.ts b/src/map/routes.ts index 9b264944..45fe9d30 100644 --- a/src/map/routes.ts +++ b/src/map/routes.ts @@ -45,9 +45,12 @@ export function routeFromFilePath(relFile: string): { route?: string; dynamic?: segs = dirs.slice(at('routes') + 1); // SvelteKit } else if (at('pages') !== -1) { segs = [...dirs.slice(at('pages') + 1), ...(base === 'index' ? [] : [base])]; // Next Pages Router - } else if (at('server') !== -1) { - // Nuxt server routes; a `.post`/`.get` suffix encodes the method, not a path segment. - segs = [...dirs.slice(at('server') + 1), ...(base === 'index' ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, '')])]; + } else if (nuxtRoot(dirs) !== undefined) { + // Nuxt server routes; a `.post`/`.get` suffix encodes the method, not a path segment. Only + // `server/api` (served under `/api`) and `server/routes` (served from the root) are routes: the rest of + // `server/` is middleware, plugins and utilities, which have no URL. + const root = nuxtRoot(dirs)!; + segs = [...root, ...(base === 'index' ? [] : [base.replace(/\.(get|post|put|patch|delete|head|options)$/i, '')])]; } if (!segs) return {}; @@ -67,6 +70,24 @@ export function routeFromFilePath(relFile: string): { route?: string; dynamic?: return { route: route.length > 1 ? route.replace(/\/+$/, '') : '/', dynamic }; } +/** Where a Nuxt server route's URL starts, from its directories; undefined outside `server/api` / `server/routes`. */ +function nuxtRoot(dirs: string[]): string[] | undefined { + const i = dirs.lastIndexOf('server'); + if (i === -1) return undefined; + if (dirs[i + 1] === 'api') return dirs.slice(i + 1); + if (dirs[i + 1] === 'routes') return dirs.slice(i + 2); + return undefined; +} + +/** + * Whether a registration's first argument is a URL path pattern. Router methods share their names with + * ordinary methods (`cache.get('user', load)`, `emitter.use('plugin', fn)`); a route path starts with + * `/`, or is the catch-all `*`. + */ +export function isRoutePath(route: string): boolean { + return route.startsWith('/') || route === '*'; +} + // Unwind `router.route('/x').get(h).post(h2)` down to the `.route('/x')` call to recover the path. export function routeFromChain(expr: any, ts: TsModule): string | undefined { let cur = expr; diff --git a/src/map/scope.ts b/src/map/scope.ts new file mode 100644 index 00000000..0202f084 --- /dev/null +++ b/src/map/scope.ts @@ -0,0 +1,116 @@ +import type { TsModule } from './types.js'; + +// Lexical binding resolution without a type checker: which declaration does an identifier refer to? +// +// A name is not an identity. `const cmd = 'ls'` and a block-scoped `const cmd = body.cmd` are different +// bindings, and so are a handler's `request` parameter and an inner callback's own `request`. Keying +// taint or helper lookups by name lets one stand in for the other. This walks the enclosing scopes of +// an occurrence — nearest first — and returns the identifier node that declares it, or `undefined` for +// a name with no declaration in the file (a global, or an ambient one). +// +// Bounded: each scope's declared names are collected once (cached per scope node), and a lookup walks +// only the occurrence's ancestors. + +const scopeCache = new WeakMap>(); + +/** The identifier node that declares the binding `id` refers to, or undefined when none is in scope. */ +export function declarationOf(id: any, ts: TsModule): any | undefined { + if (!id || !ts.isIdentifier(id)) return undefined; + const name = id.text; + for (let cur = id.parent; cur; cur = cur.parent) { + const declared = declaredIn(cur, ts); + if (declared?.has(name)) return declared.get(name); + } + return undefined; +} + +/** Whether `id` refers to the binding declared by `declaration`. */ +export function refersTo(id: any, declaration: any, ts: TsModule): boolean { + return declaration !== undefined && declarationOf(id, ts) === declaration; +} + +/** The identifier nodes a binding name (`x`, `{ a, b: c }`, `[d, ...e]`) declares. */ +export function boundIdentifiers(name: any, ts: TsModule): any[] { + if (!name) return []; + if (ts.isIdentifier(name)) return [name]; + if (ts.isObjectBindingPattern(name) || ts.isArrayBindingPattern(name)) { + return name.elements.flatMap((el: any) => (ts.isBindingElement(el) ? boundIdentifiers(el.name, ts) : [])); + } + return []; +} + +function isFunctionScope(n: any, ts: TsModule): boolean { + return ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) + || ts.isMethodDeclaration(n) || ts.isConstructorDeclaration(n) || ts.isGetAccessorDeclaration(n) + || ts.isSetAccessorDeclaration(n) || ts.isSourceFile(n); +} + +/** Names declared directly by scope node `n` (undefined when `n` opens no scope). */ +function declaredIn(n: any, ts: TsModule): Map | undefined { + const cached = scopeCache.get(n); + if (cached) return cached; + const out = new Map(); + const add = (ids: any[]) => { for (const i of ids) if (!out.has(i.text)) out.set(i.text, i); }; + + if (isFunctionScope(n, ts)) { + // A function expression's own name is visible inside it. + if ((ts.isFunctionExpression(n) || ts.isClassExpression?.(n)) && n.name) add([n.name]); + for (const p of n.parameters ?? []) add(boundIdentifiers(p.name, ts)); + // `var` and nested function declarations are function-scoped wherever they appear in its body. + collectVarScoped(ts.isSourceFile(n) ? n : n.body, ts, add); + if (ts.isSourceFile(n)) collectLexical(n.statements, ts, add, true); + } else if (ts.isBlock(n) || ts.isModuleBlock(n) || ts.isCaseBlock(n)) { + const statements = ts.isCaseBlock(n) ? n.clauses.flatMap((c: any) => [...c.statements]) : n.statements; + collectLexical(statements, ts, add, false); + } else if (ts.isForStatement(n) || ts.isForInStatement(n) || ts.isForOfStatement(n)) { + const init = n.initializer; + if (init && ts.isVariableDeclarationList(init) && isLexicalList(init, ts)) { + for (const d of init.declarations) add(boundIdentifiers(d.name, ts)); + } + } else if (ts.isCatchClause(n)) { + if (n.variableDeclaration) add(boundIdentifiers(n.variableDeclaration.name, ts)); + } else if (ts.isClassExpression?.(n) && n.name) { + add([n.name]); + } else { + return undefined; + } + scopeCache.set(n, out); + return out; +} + +function isLexicalList(list: any, ts: TsModule): boolean { + return (list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) !== 0; +} + +/** `let` / `const` / class / function declarations and imports that sit directly in a statement list. */ +function collectLexical(statements: readonly any[], ts: TsModule, add: (ids: any[]) => void, topLevel: boolean): void { + for (const s of statements ?? []) { + if (ts.isVariableStatement(s) && isLexicalList(s.declarationList, ts)) { + for (const d of s.declarationList.declarations) add(boundIdentifiers(d.name, ts)); + } else if ((ts.isClassDeclaration(s) || ts.isFunctionDeclaration(s) || ts.isEnumDeclaration(s)) && s.name) { + add([s.name]); + } else if (topLevel && ts.isImportDeclaration(s) && s.importClause) { + const clause = s.importClause; + if (clause.name) add([clause.name]); + const bindings = clause.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) add([bindings.name]); + if (bindings && ts.isNamedImports(bindings)) add(bindings.elements.map((e: any) => e.name)); + } else if (topLevel && ts.isImportEqualsDeclaration(s)) { + add([s.name]); + } + } +} + +/** `var` declarations anywhere in a function body, without entering nested functions. */ +function collectVarScoped(node: any, ts: TsModule, add: (ids: any[]) => void): void { + const visit = (n: any) => { + if (!n) return; + if (ts.isVariableDeclarationList(n) && !isLexicalList(n, ts)) { + for (const d of n.declarations) add(boundIdentifiers(d.name, ts)); + } + if (n !== node && isFunctionScope(n, ts)) return; + if (ts.isClassDeclaration(n) || ts.isClassExpression?.(n)) return; + ts.forEachChild(n, visit); + }; + visit(node); +} diff --git a/src/map/sinks.ts b/src/map/sinks.ts index 6d03bbe9..9f1a7b6d 100644 --- a/src/map/sinks.ts +++ b/src/map/sinks.ts @@ -4,12 +4,13 @@ import { isFnLike, isShadowedByEnclosingBinding, isUninvokedFunctionDeclaration, - localCalls, + localCallIdentifiers, opCallOf, rootIdentifier, spanOf, } from './ast.js'; import { npmPackageOf, type Bindings } from './bindings.js'; +import { declarationOf } from './scope.js'; const DB_OPS = new Set(['insert', 'update', 'delete', 'select', 'upsert', 'rpc']); const PRISMA_OPS = new Set(['create', 'createMany', 'update', 'updateMany', 'delete', 'deleteMany', 'upsert', 'findFirst', 'findUnique', 'findMany', 'count', 'aggregate']); @@ -112,34 +113,57 @@ export interface SinkContext { } // --- sinks (agnostic) ------------------------------------------------------- -export function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings, ctx?: SinkContext): Map { - const map = new Map(); - const visit = (node: any) => { - if (ts.isFunctionDeclaration(node) && node.name && node.body) map.set(node.name.text, directSinks(node.body, ts, bindings, ctx)); +/** + * A file's helper functions and the sinks each one reaches. + * + * `byDeclaration` is keyed by the helper's declaring identifier, so a call reaches the helper its name + * resolves to in scope: two handlers that each define their own `run` get their own sinks. `byName` is + * for callers that only have an exported name to go on; a top-level declaration wins over a nested one. + */ +export interface LocalSinks { + byDeclaration: Map; + byName: Map; +} + +export function collectLocalSinks(sf: any, ts: TsModule, bindings: Bindings, ctx?: SinkContext): LocalSinks { + const byDeclaration = new Map(); + const byName = new Map(); + const record = (name: any, body: any, topLevel: boolean) => { + const sinks = directSinks(body, ts, bindings, ctx); + byDeclaration.set(name, sinks); + if (topLevel || !byName.has(name.text)) byName.set(name.text, sinks); + }; + const visit = (node: any, depth: number) => { + const topLevel = depth === 0; + if (ts.isFunctionDeclaration(node) && node.name && node.body) record(node.name, node.body, topLevel); else if (ts.isVariableStatement(node)) { for (const decl of node.declarationList.declarations) { if (ts.isIdentifier(decl.name) && decl.initializer && isFnLike(decl.initializer, ts)) { - map.set(decl.name.text, directSinks(decl.initializer.body, ts, bindings, ctx)); + record(decl.name, decl.initializer.body, topLevel); } } } - ts.forEachChild(node, visit); + ts.forEachChild(node, (child: any) => visit(child, depth + (ts.isSourceFile(node) ? 0 : 1))); }; - visit(sf); - return map; + visit(sf, 0); + return { byDeclaration, byName }; } -export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map, bindings: Bindings, ctx?: SinkContext): Sink[] { +export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: LocalSinks, bindings: Bindings, ctx?: SinkContext): Sink[] { if (!arrowOrNode) return []; const body = arrowOrNode.isSyntheticBody ? arrowOrNode.body : isFnLike(arrowOrNode, ts) ? arrowOrNode.body : arrowOrNode; if (!body) return []; const sinks = directSinks(body, ts, bindings, ctx); - for (const called of localCalls(body, ts)) { - // Same-file helper. - for (const s of localSinks.get(called) ?? []) sinks.push(s); - // Imported helper: the name resolves to a RELATIVE module → follow one hop into it. - if (ctx) { + for (const callee of localCallIdentifiers(body, ts)) { + const called = callee.text; + const declaration = declarationOf(callee, ts); + // Same-file helper: the one this call's name resolves to in scope. + const helper = declaration === undefined ? undefined : localSinks.byDeclaration.get(declaration); + for (const s of helper ?? []) sinks.push(s); + // Imported helper: the name resolves to a RELATIVE module → follow one hop into it. Only when the + // name is not bound by something closer, such as a parameter or a local of the same name. + if (ctx && (declaration === undefined || isModuleLevel(declaration, ts))) { const spec = bindings.resolve(called); if (spec && spec.startsWith('.')) { for (const s of ctx.graph.importedSinks(ctx.file, spec, bindings.exportNameOf(called) ?? called)) sinks.push(s); @@ -161,6 +185,18 @@ export function sinksFrom(arrowOrNode: any, ts: TsModule, localSinks: Map { const out: Array<[string, string]> = []; @@ -232,7 +268,7 @@ function directSinks(node: any, ts: TsModule, bindings: Bindings, ctx?: SinkCont const visit = (n: any) => { // A function that is DECLARED here but not invoked here is not reached by this endpoint — walking // into it would report sinks the endpoint never touches (e.g. an unused local helper that shells - // out). Skip those subtrees; when the handler DOES call such a helper, `localCalls` + + // out). Skip those subtrees; when the handler DOES call such a helper, `localCallIdentifiers` + // `collectLocalSinks` bring its sinks in by name. Inline callbacks / IIFEs are NOT skipped — those // do run (`items.map(x => db.insert(x))`, `.then(...)`). if (n !== node && isUninvokedFunctionDeclaration(n, ts)) return; diff --git a/tests/map/binding-scope.test.ts b/tests/map/binding-scope.test.ts new file mode 100644 index 00000000..ee667c44 --- /dev/null +++ b/tests/map/binding-scope.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildInputMap } from '../../src/map/index.js'; + +/** + * A request binding is a variable, not a name. Another binding that happens to share its name — in a + * block, a callback parameter, an inner function's own `request` — is a different variable, and a read + * of it is not a read of the request. Where one variable is assigned again, what reaches a sink through + * it may no longer be the request value, so the read proves reachability but not an exact value. + */ +async function mapOf(files: Record, deps: Record = { next: '15.0.0' }) { + const dir = mkdtempSync(path.join(tmpdir(), 'ps-scope-')); + try { + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: deps })); + for (const [file, source] of Object.entries(files)) { + mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + writeFileSync(path.join(dir, file), source); + } + const { map } = await buildInputMap(dir, {}); + + return map!; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +const route = 'app/api/run/route.ts'; +const handler = (body: string) => + `import { exec } from 'node:child_process';\nexport async function POST(request: Request) {\n${body}\n return new Response('ok');\n}\n`; +const flowOf = async (body: string, input = 'cmd') => { + const map = await mapOf({ [route]: handler(body) }); + + return map.endpoints[0]!.flows.find((f) => f.input === input); +}; + +describe('taint follows the binding, not its name', () => { + it('proves a read through the request binding itself', async () => { + const flow = await flowOf(" const body = await request.json();\n const cmd = body.cmd;\n exec(cmd);"); + + expect(flow).toMatchObject({ confidence: 'exact-local', ruleGeneratable: true }); + }); + + it('does not read a block-scoped binding as an outer one of the same name', async () => { + const flow = await flowOf(" const body = await request.json();\n const cmd = 'ls';\n { const cmd = body.cmd; console.log(cmd); }\n exec(cmd);"); + + expect(flow?.confidence).toBe('heuristic'); + expect(flow?.ruleGeneratable).toBe(false); + }); + + it('does not read a callback parameter as a request binding of the same name', async () => { + const flow = await flowOf(" const body = await request.json();\n const cmd = body.cmd;\n console.log(cmd);\n ['ls'].forEach((cmd) => exec(cmd));"); + + expect(flow?.confidence).toBe('heuristic'); + }); + + it("does not read an inner function's own parameter as the request", async () => { + const flow = await flowOf(" const { cmd } = await request.json();\n console.log(cmd);\n [{ cmd: 'ls' }].map((request) => exec(request.cmd));"); + + expect(flow?.confidence).toBe('heuristic'); + }); + + it("does not inventory fields read off an inner function's own parameter", async () => { + const map = await mapOf({ [route]: handler(" const rows = [{ query: { id: 1 } }];\n rows.map((request) => request.query.id);") }); + + expect(map.endpoints[0]!.inputs).toEqual([]); + }); + + it('still inventories fields read off the request inside a callback', async () => { + const map = await mapOf({ [route]: handler(" [1].map(() => request.headers.get('x-trace'));") }); + + expect(map.endpoints[0]!.inputs.map((i) => i.name)).toEqual(['x-trace']); + }); + + it('does not claim an exact value through a binding that is assigned again', async () => { + const flow = await flowOf(" const body = await request.json();\n let cmd = body.cmd;\n cmd = 'ls';\n exec(cmd);"); + + expect(flow?.confidence).toBe('transformed-local'); + }); + + it('carries the reassignment to aliases taken from the reassigned binding', async () => { + const flow = await flowOf(" let body = await request.json();\n if (!body.cmd) body = { cmd: 'ls' };\n const { cmd } = body;\n exec(cmd);"); + + expect(flow?.confidence).toBe('transformed-local'); + }); + + it.each([ + ['a compound assignment', " cmd += ' --help';"], + ['an update expression', ' cmd++;'], + ['a destructuring assignment', " ({ cmd } = { cmd: 'ls' });"], + ['an array destructuring assignment', " [cmd] = ['ls'];"], + ['a for-of head', " for (cmd of ['ls']) console.log(cmd);"], + ])('treats %s as a reassignment', async (_label, statement) => { + const flow = await flowOf(` const body = await request.json();\n let cmd = body.cmd;\n${statement}\n exec(cmd);`); + + expect(flow?.confidence).toBe('transformed-local'); + }); + + it('reports a dependency argument through a reassigned binding as within an expression', async () => { + const map = await mapOf({ + 'src/server.ts': "import express from 'express';\nimport reader from '@example/reader';\nconst app = express();\napp.post('/scan', (req, res) => {\n let host = req.body.host;\n host = host.trim();\n reader.scan(host);\n res.end();\n});\n", + }, { express: '4', '@example/reader': '1' }); + const [link] = map.endpoints[0]!.dependencyInputFlows ?? []; + + expect(link).toMatchObject({ input: 'host', argumentUse: 'within-expression' }); + }); +}); + +describe('same-file helpers resolve by scope', () => { + const twoHandlers = (first: 'exec' | 'log') => { + const execRun = " const run = (v: string) => exec(v);"; + const logRun = " const run = (v: string) => console.log(v);"; + return `import { exec } from 'node:child_process';\n` + + `export async function POST(request: Request) {\n${first === 'exec' ? execRun : logRun}\n run(request.headers.get('x-a'));\n return new Response('');\n}\n` + + `export async function PUT(request: Request) {\n${first === 'exec' ? logRun : execRun}\n run(request.headers.get('x-b'));\n return new Response('');\n}\n`; + }; + const sinkKinds = (map: any, method: string) => map.endpoints.find((e: any) => e.method === method).sinks.map((s: any) => s.kind); + + it.each(['exec', 'log'] as const)('gives each handler its own local helper (exec helper in %s-first order)', async (first) => { + const map = await mapOf({ [route]: twoHandlers(first) }); + + expect(sinkKinds(map, first === 'exec' ? 'POST' : 'PUT')).toEqual(['exec']); + expect(sinkKinds(map, first === 'exec' ? 'PUT' : 'POST')).toEqual([]); + }); + + it('still follows a module-level helper', async () => { + const map = await mapOf({ + [route]: "import { exec } from 'node:child_process';\nfunction run(v: string) { exec(v); }\nexport async function POST(request: Request) {\n run(request.headers.get('x-a'));\n return new Response('');\n}\n", + }); + + expect(sinkKinds(map, 'POST')).toEqual(['exec']); + }); + + it('does not follow an imported helper when a local of the same name is called', async () => { + const map = await mapOf({ + 'lib/save.ts': "import { exec } from 'node:child_process';\nexport function save(v: string) { exec(v); }\n", + [route]: "import { save } from '../../../lib/save';\nexport async function POST(request: Request) {\n const save = (v: string) => console.log(v);\n save(request.headers.get('x-a'));\n return new Response('');\n}\n", + }); + + expect(sinkKinds(map, 'POST')).toEqual([]); + }); + + it('follows the exported helper, not a nested function of the same name in that module', async () => { + const map = await mapOf({ + 'lib/save.ts': "import { exec } from 'node:child_process';\nexport function save(v: string) { exec(v); }\nexport function other() {\n const save = () => 1;\n return save();\n}\n", + [route]: "import { save } from '../../../lib/save';\nexport async function POST(request: Request) {\n save(request.headers.get('x-a'));\n return new Response('');\n}\n", + }); + + expect(sinkKinds(map, 'POST')).toEqual(['exec']); + }); + + it('follows the imported helper when that is what the call names', async () => { + const map = await mapOf({ + 'lib/save.ts': "import { exec } from 'node:child_process';\nexport function save(v: string) { exec(v); }\n", + [route]: "import { save } from '../../../lib/save';\nexport async function POST(request: Request) {\n save(request.headers.get('x-a'));\n return new Response('');\n}\n", + }); + + expect(sinkKinds(map, 'POST')).toEqual(['exec']); + }); +}); diff --git a/tests/map/route-recognition.test.ts b/tests/map/route-recognition.test.ts new file mode 100644 index 00000000..0de53318 --- /dev/null +++ b/tests/map/route-recognition.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { buildInputMap } from '../../src/map/index.js'; +import { routeFromFilePath } from '../../src/map/extract.js'; + +/** + * A route in the map is a URL the app serves. A rule scoped to a route the app does not serve never + * matches, so a call that merely shares a router method's name, or a file whose location is not a URL, + * must not produce one. + */ +async function endpointsOf(file: string, source: string, deps: Record) { + const dir = mkdtempSync(path.join(tmpdir(), 'ps-routes-')); + try { + mkdirSync(path.join(dir, path.dirname(file)), { recursive: true }); + writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'x', dependencies: deps })); + writeFileSync(path.join(dir, file), source); + const { map } = await buildInputMap(dir, {}); + + return map!.endpoints; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe('route registrations', () => { + it('does not register a route for a method call whose first argument is not a path', async () => { + const endpoints = await endpointsOf('src/server.ts', + "import express from 'express';\nconst app = express();\nconst cache = new Map();\ncache.get('user', (req: any) => req.body.x);\napp.get('/users', (req, res) => res.send(req.query.id));\n", + { express: '4' }); + + expect(endpoints.map((e) => e.route)).toEqual(['/users']); + }); + + it('registers a catch-all route', async () => { + const endpoints = await endpointsOf('src/server.ts', + "import express from 'express';\nconst app = express();\napp.all('*', (req, res) => res.send(req.query.id));\n", + { express: '4' }); + + expect(endpoints.map((e) => e.route)).toEqual(['*']); + }); + + it('does not register an object-form route whose url is not a path', async () => { + const endpoints = await endpointsOf('src/server.ts', + "import Fastify from 'fastify';\nconst app = Fastify();\njobs.route({ method: 'GET', url: 'nightly', handler: async (req) => req.query.id });\napp.route({ method: 'GET', url: '/items', handler: async (req) => req.query.id });\n", + { fastify: '4' }); + + expect(endpoints.map((e) => e.route)).toEqual(['/items']); + }); +}); + +describe('file-based routes', () => { + it.each(['src/pages/actions.ts', 'server/api/actions.ts'])('gives a server action in %s no route from its file location', async (file) => { + const endpoints = await endpointsOf(file, + "'use server';\nimport { exec } from 'node:child_process';\nexport async function report(input: any) { exec(input.job); }\n", + { next: '15' }); + + expect(endpoints).toHaveLength(1); + expect(endpoints[0]).toMatchObject({ entryKind: 'server-action' }); + expect(endpoints[0]!.route).toBeUndefined(); + }); + + it.each([ + ['server/api/items.post.ts', '/api/items'], + ['server/api/items/[id].ts', '/api/items/:id'], + ['server/routes/hello.ts', '/hello'], + ['server/routes/index.ts', '/'], + ])('maps the Nuxt server route %s to %s', (file, expected) => { + expect(routeFromFilePath(file).route).toBe(expected); + }); + + it.each(['server/utils/db.ts', 'server/middleware/auth.ts', 'src/server/handlers.ts'])('gives %s no route', (file) => { + expect(routeFromFilePath(file).route).toBeUndefined(); + }); +});