mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 13: Track L.11 — Express / Koa / NestJS / Fastify adapters
This commit is contained in:
parent
9ed837be9b
commit
04bf7b997f
27 changed files with 2670 additions and 11 deletions
166
src/dynamic/framework/adapters/js_express.rs
Normal file
166
src/dynamic/framework/adapters/js_express.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
//! Express [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11).
|
||||
//!
|
||||
//! Recognises `app.get('/path', handler)`, `app.post('/path', handler)`,
|
||||
//! `router.put('/path', handler)`, and the rest of the Express verb
|
||||
//! dispatch surface (`get` / `head` / `post` / `put` / `patch` /
|
||||
//! `delete` / `del` / `options` / `all`). Middleware-chained
|
||||
//! registrations (`app.get('/x', authz, validate, handler)`) bind to
|
||||
//! the last positional argument that references `summary.name`.
|
||||
//!
|
||||
//! Receiver aliases follow Express convention: bare `app`,
|
||||
//! `application`, `router`, `api`, plus any name ending in `_router` /
|
||||
//! `_app` / `Router` / `App`. Source-import sniffing requires one of
|
||||
//! the well-known Express stanzas before the AST walk runs.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::js_routes::{
|
||||
bind_path_params, find_function_params, find_route_registration, function_formal_names,
|
||||
source_imports_express,
|
||||
};
|
||||
|
||||
pub struct JsExpressAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "js-express";
|
||||
|
||||
fn receiver_looks_like_express(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"app" | "application" | "router" | "api" | "expressApp" | "server"
|
||||
) || name.ends_with("_router")
|
||||
|| name.ends_with("_app")
|
||||
|| name.ends_with("Router")
|
||||
|| name.ends_with("App")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for JsExpressAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_express(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let recv = receiver_looks_like_express;
|
||||
let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?;
|
||||
let formals = find_function_params(ast, file_bytes, &summary.name)
|
||||
.map(|p| function_formal_names(p, file_bytes))
|
||||
.unwrap_or_default();
|
||||
let request_params = bind_path_params(&formals, &path);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dynamic::framework::{HttpMethod, ParamSource};
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: "javascript".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_app_get_with_named_handler() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const app = express();\n\
|
||||
function getUser(req, res) { res.send(req.params.id); }\n\
|
||||
app.get('/users/:id', getUser);\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsExpressAdapter
|
||||
.detect(&summary("getUser"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "js-express");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.as_ref().unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/users/:id");
|
||||
assert!(binding.request_params.iter().any(|p| p.name == "req"
|
||||
&& matches!(p.source, ParamSource::Implicit)));
|
||||
assert!(binding.request_params.iter().any(|p| p.name == "res"
|
||||
&& matches!(p.source, ParamSource::Implicit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_post_via_router_alias() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const apiRouter = express.Router();\n\
|
||||
function saveItem(req, res) { res.json(req.body); }\n\
|
||||
apiRouter.post('/items', saveItem);\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsExpressAdapter
|
||||
.detect(&summary("saveItem"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.as_ref().unwrap().method, HttpMethod::POST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_middleware_chain() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const app = express();\n\
|
||||
function authz(req, res, next) { next(); }\n\
|
||||
function handler(req, res) { res.send('ok'); }\n\
|
||||
app.delete('/items/:id', authz, handler);\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsExpressAdapter
|
||||
.detect(&summary("handler"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_express_not_imported() {
|
||||
let src: &[u8] = b"const koa = require('koa');\n\
|
||||
const app = new koa();\n\
|
||||
function handler(ctx) { ctx.body = 'ok'; }\n\
|
||||
app.get('/x', handler);\n";
|
||||
let tree = parse_js(src);
|
||||
assert!(JsExpressAdapter
|
||||
.detect(&summary("handler"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_handler_name_does_not_match() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const app = express();\n\
|
||||
function other(req, res) { res.send('x'); }\n\
|
||||
app.get('/x', other);\n";
|
||||
let tree = parse_js(src);
|
||||
assert!(JsExpressAdapter
|
||||
.detect(&summary("missing"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
155
src/dynamic/framework/adapters/js_fastify.rs
Normal file
155
src/dynamic/framework/adapters/js_fastify.rs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
//! Fastify [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11).
|
||||
//!
|
||||
//! Recognises three Fastify route-registration shapes:
|
||||
//! - Verb dispatch: `fastify.get('/path', handler)`,
|
||||
//! `fastify.post(...)`, `fastify.put(...)`, etc.
|
||||
//! - Options-object: `fastify.route({ method: 'GET', url: '/path',
|
||||
//! handler })`.
|
||||
//! - Plugin route table: `fastify.register(async (instance, opts) =>
|
||||
//! { instance.get('/path', handler); })` — Phase 13 v1 fires the
|
||||
//! inner verb dispatch directly (the outer plugin wrapper is
|
||||
//! opaque to the AST walk).
|
||||
//!
|
||||
//! Receiver aliases cover the canonical Fastify names (`fastify`,
|
||||
//! `server`, `instance`, `app`) plus any name ending in `_fastify` /
|
||||
//! `_server` / `Server` / `Fastify`.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::js_routes::{
|
||||
bind_path_params, find_function_params, find_route_registration, function_formal_names,
|
||||
source_imports_fastify,
|
||||
};
|
||||
|
||||
pub struct JsFastifyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "js-fastify";
|
||||
|
||||
fn receiver_looks_like_fastify(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"fastify" | "server" | "instance" | "app" | "application"
|
||||
) || name.ends_with("_fastify")
|
||||
|| name.ends_with("_server")
|
||||
|| name.ends_with("Server")
|
||||
|| name.ends_with("Fastify")
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for JsFastifyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_fastify(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let recv = receiver_looks_like_fastify;
|
||||
let (method, path) = find_route_registration(ast, file_bytes, &summary.name, &recv)?;
|
||||
let formals = find_function_params(ast, file_bytes, &summary.name)
|
||||
.map(|p| function_formal_names(p, file_bytes))
|
||||
.unwrap_or_default();
|
||||
let request_params = bind_path_params(&formals, &path);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: "javascript".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_fastify_get() {
|
||||
let src: &[u8] = b"const fastify = require('fastify')();\n\
|
||||
async function getUser(request, reply) { reply.send(request.params.id); }\n\
|
||||
fastify.get('/users/:id', getUser);\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsFastifyAdapter
|
||||
.detect(&summary("getUser"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "js-fastify");
|
||||
let route = binding.route.as_ref().unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/users/:id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_options_object_route() {
|
||||
let src: &[u8] = b"const fastify = require('fastify')();\n\
|
||||
async function handler(request, reply) { reply.send('ok'); }\n\
|
||||
fastify.route({ method: 'POST', url: '/items', handler: handler });\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsFastifyAdapter
|
||||
.detect(&summary("handler"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(route.path, "/items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_plugin_inner_verb_dispatch() {
|
||||
// Phase 13 v1: the inner `instance.get(...)` registration is
|
||||
// recognised even though the surrounding `fastify.register`
|
||||
// plugin wrapper is opaque to the AST walk. Fastify's
|
||||
// `instance` alias matches `receiver_looks_like_fastify`.
|
||||
let src: &[u8] = b"const fastify = require('fastify')();\n\
|
||||
async function handler(request, reply) { reply.send('ok'); }\n\
|
||||
fastify.register(async (instance, opts) => {\n\
|
||||
instance.get('/inner', handler);\n\
|
||||
});\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsFastifyAdapter
|
||||
.detect(&summary("handler"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.unwrap().path, "/inner");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_fastify_not_imported() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const app = express();\n\
|
||||
function h(req, res) {}\n\
|
||||
app.get('/x', h);\n";
|
||||
let tree = parse_js(src);
|
||||
assert!(JsFastifyAdapter
|
||||
.detect(&summary("h"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
212
src/dynamic/framework/adapters/js_koa.rs
Normal file
212
src/dynamic/framework/adapters/js_koa.rs
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
//! Koa [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11).
|
||||
//!
|
||||
//! Recognises `@koa/router` / `koa-router` route registrations
|
||||
//! (`router.get('/path', handler)` etc.) plus bare `app.use(handler)`
|
||||
//! middleware chains. The Koa adapter accepts the `router` / `koa-router`
|
||||
//! verb dispatch surface (`get` / `post` / `put` / `patch` / `delete` /
|
||||
//! `head` / `options` / `all`) and also matches the legacy `app.use`
|
||||
//! middleware shape which has no path template (route is recorded as
|
||||
//! `"/"`).
|
||||
|
||||
use crate::dynamic::framework::{
|
||||
FrameworkAdapter, FrameworkBinding, HttpMethod, MiddlewareShape, RouteShape,
|
||||
};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::js_routes::{
|
||||
bind_path_params, find_function_params, find_route_registration, function_formal_names,
|
||||
last_segment, source_imports_koa, view_arg_references,
|
||||
};
|
||||
|
||||
pub struct JsKoaAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "js-koa";
|
||||
|
||||
fn receiver_looks_like_koa(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"router" | "app" | "application" | "koaApp" | "koaRouter" | "api"
|
||||
) || name.ends_with("Router")
|
||||
|| name.ends_with("App")
|
||||
|| name.ends_with("_router")
|
||||
|| name.ends_with("_app")
|
||||
}
|
||||
|
||||
/// Walk `root` looking for `app.use(handler)` middleware registrations
|
||||
/// that reference `target`. Returns the matched call node so callers
|
||||
/// can stamp a middleware-shape binding when the verb-based dispatch
|
||||
/// fails to fire.
|
||||
fn find_use_middleware<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
) -> Option<Node<'a>> {
|
||||
let mut hit: Option<Node<'a>> = None;
|
||||
walk_for_use(root, bytes, target, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn walk_for_use<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
out: &mut Option<Node<'a>>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call_expression"
|
||||
&& let Some(callee) = node.child_by_field_name("function")
|
||||
&& callee.kind() == "member_expression"
|
||||
&& let Some(prop) = callee.child_by_field_name("property")
|
||||
&& let Some(prop_text) = prop.utf8_text(bytes).ok()
|
||||
&& prop_text == "use"
|
||||
&& let Some(object) = callee.child_by_field_name("object")
|
||||
&& let Some(obj_text) = object.utf8_text(bytes).ok()
|
||||
&& receiver_looks_like_koa(last_segment(obj_text))
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
{
|
||||
let mut cur = args.walk();
|
||||
for c in args.named_children(&mut cur) {
|
||||
if view_arg_references(c, bytes, target) {
|
||||
*out = Some(node);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_for_use(child, bytes, target, out);
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for JsKoaAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_koa(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let recv = receiver_looks_like_koa;
|
||||
let formals_for = |path: &str| {
|
||||
let formals = find_function_params(ast, file_bytes, &summary.name)
|
||||
.map(|p| function_formal_names(p, file_bytes))
|
||||
.unwrap_or_default();
|
||||
bind_path_params(&formals, path)
|
||||
};
|
||||
if let Some((method, path)) =
|
||||
find_route_registration(ast, file_bytes, &summary.name, &recv)
|
||||
{
|
||||
let request_params = formals_for(&path);
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape { method, path }),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
});
|
||||
}
|
||||
// Fall back to `app.use(handler)` middleware registration. No
|
||||
// verb / path information — record the binding so the harness
|
||||
// still drives the middleware via a synthetic ctx.
|
||||
if find_use_middleware(ast, file_bytes, &summary.name).is_some() {
|
||||
let request_params = formals_for("/");
|
||||
return Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: HttpMethod::GET,
|
||||
path: "/".to_owned(),
|
||||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: vec![MiddlewareShape {
|
||||
name: "koa.use".to_owned(),
|
||||
}],
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dynamic::framework::ParamSource;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: "javascript".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_router_get() {
|
||||
let src: &[u8] = b"const Koa = require('koa');\n\
|
||||
const Router = require('@koa/router');\n\
|
||||
const app = new Koa();\n\
|
||||
const router = new Router();\n\
|
||||
async function getUser(ctx) { ctx.body = ctx.params.id; }\n\
|
||||
router.get('/users/:id', getUser);\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsKoaAdapter
|
||||
.detect(&summary("getUser"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "js-koa");
|
||||
let route = binding.route.as_ref().unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/users/:id");
|
||||
assert!(binding.request_params.iter().any(|p| p.name == "ctx"
|
||||
&& matches!(p.source, ParamSource::Implicit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_app_use_middleware() {
|
||||
let src: &[u8] = b"const Koa = require('koa');\n\
|
||||
const app = new Koa();\n\
|
||||
async function logger(ctx, next) { await next(); }\n\
|
||||
app.use(logger);\n";
|
||||
let tree = parse_js(src);
|
||||
let binding = JsKoaAdapter
|
||||
.detect(&summary("logger"), tree.root_node(), src)
|
||||
.expect("middleware binding");
|
||||
assert_eq!(binding.middleware.len(), 1);
|
||||
assert_eq!(binding.middleware[0].name, "koa.use");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_koa_not_imported() {
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const router = express.Router();\n\
|
||||
function h(req, res) {}\n\
|
||||
router.get('/x', h);\n";
|
||||
let tree = parse_js(src);
|
||||
assert!(JsKoaAdapter
|
||||
.detect(&summary("h"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
569
src/dynamic/framework/adapters/js_nest.rs
Normal file
569
src/dynamic/framework/adapters/js_nest.rs
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
//! NestJS [`super::super::FrameworkAdapter`] (Phase 13 — Track L.11).
|
||||
//!
|
||||
//! Recognises Nest's controller-class decorator surface:
|
||||
//! - `@Controller('users')` on the class establishes the route
|
||||
//! prefix.
|
||||
//! - `@Get(':id')` / `@Post()` / `@Put('/x')` / `@Patch()` /
|
||||
//! `@Delete()` / `@Head()` / `@Options()` / `@All()` on the
|
||||
//! method establishes the verb + sub-path; the full route is the
|
||||
//! concatenation `prefix + path`.
|
||||
//! - Parameter decorators (`@Param('id')`, `@Query('q')`,
|
||||
//! `@Body()`, `@Headers()`, `@Req()`, `@Res()`) bind individual
|
||||
//! formals to request slots.
|
||||
//!
|
||||
//! NestJS is TypeScript-first. The adapter is registered under both
|
||||
//! [`Lang::TypeScript`] and [`Lang::JavaScript`] so Babel-transpiled
|
||||
//! Nest projects (still common in the wild) are not silently
|
||||
//! skipped — JS Nest projects emit the same decorator syntax via
|
||||
//! `experimentalDecorators` / `legacyDecorators`. The lang-aware
|
||||
//! tree-sitter parser is picked from `summary.lang`.
|
||||
|
||||
use crate::dynamic::framework::{
|
||||
FrameworkAdapter, FrameworkBinding, HttpMethod, ParamBinding, ParamSource, RouteShape,
|
||||
};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::js_routes::{
|
||||
bind_path_params, extract_path_placeholders, function_formal_names, http_verb_from_method,
|
||||
source_imports_nest, strip_quotes,
|
||||
};
|
||||
|
||||
pub struct JsNestAdapter;
|
||||
pub struct TsNestAdapter;
|
||||
|
||||
const JS_ADAPTER_NAME: &str = "js-nest";
|
||||
const TS_ADAPTER_NAME: &str = "ts-nest";
|
||||
|
||||
impl FrameworkAdapter for JsNestAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
JS_ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::JavaScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_nest(summary, ast, file_bytes, JS_ADAPTER_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for TsNestAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
TS_ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::TypeScript
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_nest(summary, ast, file_bytes, TS_ADAPTER_NAME)
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_nest(
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
adapter_name: &'static str,
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_nest(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let (class_node, method_node) =
|
||||
find_class_method(ast, file_bytes, &summary.name)?;
|
||||
let prefix = class_controller_prefix(class_node, file_bytes)?;
|
||||
let (method, sub_path) = method_verb_and_path(method_node, file_bytes)?;
|
||||
let full_path = join_paths(&prefix, &sub_path);
|
||||
let formals = method_node
|
||||
.child_by_field_name("parameters")
|
||||
.map(|p| function_formal_names(p, file_bytes))
|
||||
.unwrap_or_default();
|
||||
let mut request_params = bind_path_params(&formals, &full_path);
|
||||
refine_with_param_decorators(method_node, file_bytes, &mut request_params, &full_path);
|
||||
Some(FrameworkBinding {
|
||||
adapter: adapter_name.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method,
|
||||
path: full_path,
|
||||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Find `(class_declaration, method_definition)` where the method's
|
||||
/// `name` field equals `target` and the enclosing class is decorated
|
||||
/// with `@Controller(...)`. Returns the first match in document
|
||||
/// order.
|
||||
fn find_class_method<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
) -> Option<(Node<'a>, Node<'a>)> {
|
||||
let mut hit: Option<(Node<'a>, Node<'a>)> = None;
|
||||
walk_for_class_method(root, bytes, target, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn walk_for_class_method<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
out: &mut Option<(Node<'a>, Node<'a>)>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "class_declaration"
|
||||
&& class_has_controller(node, bytes)
|
||||
&& let Some(body) = node.child_by_field_name("body")
|
||||
{
|
||||
let mut cur = body.walk();
|
||||
for child in body.named_children(&mut cur) {
|
||||
if child.kind() == "method_definition"
|
||||
&& let Some(name) = child
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& name == target
|
||||
{
|
||||
*out = Some((node, child));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_for_class_method(child, bytes, target, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when `class_node` is preceded by (or contains, depending on
|
||||
/// grammar version) an `@Controller(...)` decorator. The walk
|
||||
/// inspects both the class's own `decorator` field children
|
||||
/// (tree-sitter-typescript) and its preceding siblings in the parent
|
||||
/// (tree-sitter-javascript with legacy decorator transform), so the
|
||||
/// adapter fires regardless of the grammar's wrapping.
|
||||
fn class_has_controller(class_node: Node<'_>, bytes: &[u8]) -> bool {
|
||||
if decorator_named(class_node, bytes, "Controller", &mut |_| {}) {
|
||||
return true;
|
||||
}
|
||||
let mut prev = class_node.prev_named_sibling();
|
||||
while let Some(sib) = prev {
|
||||
if sib.kind() == "decorator" {
|
||||
if decorator_text_is(sib, bytes, "Controller") {
|
||||
return true;
|
||||
}
|
||||
prev = sib.prev_named_sibling();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the controller-prefix string from a class's
|
||||
/// `@Controller(<prefix>)` decorator. Returns `Some("")` when the
|
||||
/// decorator carries no argument (`@Controller()` is valid Nest — it
|
||||
/// mounts the controller at root).
|
||||
fn class_controller_prefix(class_node: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
let mut found: Option<String> = None;
|
||||
let mut catcher = |text: Option<&str>| {
|
||||
if let Some(t) = text {
|
||||
found = Some(t.to_owned());
|
||||
} else if found.is_none() {
|
||||
found = Some(String::new());
|
||||
}
|
||||
};
|
||||
if decorator_named(class_node, bytes, "Controller", &mut catcher) {
|
||||
return found;
|
||||
}
|
||||
let mut prev = class_node.prev_named_sibling();
|
||||
while let Some(sib) = prev {
|
||||
if sib.kind() == "decorator" {
|
||||
if decorator_text_is(sib, bytes, "Controller") {
|
||||
let arg = decorator_first_string_arg(sib, bytes);
|
||||
return Some(arg.unwrap_or_default());
|
||||
}
|
||||
prev = sib.prev_named_sibling();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Return `Some((verb, sub_path))` when `method_node` is decorated
|
||||
/// with one of the Nest verb decorators (`@Get`, `@Post`, ...). The
|
||||
/// `sub_path` is `""` when the decorator carries no argument
|
||||
/// (`@Get()` mounts at the controller prefix root).
|
||||
fn method_verb_and_path(
|
||||
method_node: Node<'_>,
|
||||
bytes: &[u8],
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
const VERBS: &[&str] = &[
|
||||
"Get", "Head", "Post", "Put", "Patch", "Delete", "Options", "All",
|
||||
];
|
||||
for &verb in VERBS {
|
||||
if decorator_named(method_node, bytes, verb, &mut |_| {})
|
||||
&& let Some(method) = http_verb_from_method(verb)
|
||||
{
|
||||
let path = method_decorator_path(method_node, bytes, verb);
|
||||
return Some((method, path));
|
||||
}
|
||||
}
|
||||
// Phase 13 v1: also accept preceding-sibling decorators for
|
||||
// grammar variants that hoist method decorators out of the
|
||||
// method_definition node.
|
||||
let mut prev = method_node.prev_named_sibling();
|
||||
while let Some(sib) = prev {
|
||||
if sib.kind() == "decorator" {
|
||||
for &verb in VERBS {
|
||||
if decorator_text_is(sib, bytes, verb)
|
||||
&& let Some(method) = http_verb_from_method(verb)
|
||||
{
|
||||
let path = decorator_first_string_arg(sib, bytes).unwrap_or_default();
|
||||
return Some((method, path));
|
||||
}
|
||||
}
|
||||
prev = sib.prev_named_sibling();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn method_decorator_path(method_node: Node<'_>, bytes: &[u8], verb: &str) -> String {
|
||||
let mut cur = method_node.walk();
|
||||
for d in method_node.children_by_field_name("decorator", &mut cur) {
|
||||
if decorator_text_is(d, bytes, verb) {
|
||||
return decorator_first_string_arg(d, bytes).unwrap_or_default();
|
||||
}
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
|
||||
/// Walk `node`'s `decorator` field children invoking `callback` for
|
||||
/// each decorator named `name`. Returns `true` when at least one
|
||||
/// matching decorator was found. `callback` receives the first
|
||||
/// string argument (or `None` when the decorator carries no
|
||||
/// arguments).
|
||||
fn decorator_named(
|
||||
node: Node<'_>,
|
||||
bytes: &[u8],
|
||||
name: &str,
|
||||
callback: &mut dyn FnMut(Option<&str>),
|
||||
) -> bool {
|
||||
let mut found = false;
|
||||
let mut cur = node.walk();
|
||||
for d in node.children_by_field_name("decorator", &mut cur) {
|
||||
if decorator_text_is(d, bytes, name) {
|
||||
found = true;
|
||||
let arg = decorator_first_string_arg(d, bytes);
|
||||
callback(arg.as_deref());
|
||||
}
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
fn decorator_text_is(decorator: Node<'_>, bytes: &[u8], name: &str) -> bool {
|
||||
let mut cur = decorator.walk();
|
||||
for c in decorator.children(&mut cur) {
|
||||
if c.kind() == "@" {
|
||||
continue;
|
||||
}
|
||||
let text = c.utf8_text(bytes).unwrap_or("");
|
||||
// Strip optional `(args)` so `@Get(':id')` matches the name `Get`.
|
||||
let head = text.split('(').next().unwrap_or(text).trim();
|
||||
if head == name {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn decorator_first_string_arg(decorator: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
let mut cur = decorator.walk();
|
||||
for c in decorator.children(&mut cur) {
|
||||
if c.kind() == "call_expression"
|
||||
&& let Some(args) = c.child_by_field_name("arguments")
|
||||
{
|
||||
let mut ac = args.walk();
|
||||
for a in args.named_children(&mut ac) {
|
||||
if a.kind() == "string" || a.kind() == "template_string" {
|
||||
let raw = a.utf8_text(bytes).ok()?;
|
||||
return Some(strip_quotes(raw).to_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Refine the per-formal binding shape using Nest's parameter
|
||||
/// decorators (`@Param('id')`, `@Query('q')`, `@Body()`, `@Headers()`,
|
||||
/// `@Req()` / `@Res()`). A `@Body()` formal becomes
|
||||
/// [`ParamSource::JsonBody`]; a `@Param('x')` formal becomes
|
||||
/// [`ParamSource::PathSegment`]; `@Query('q')` keeps
|
||||
/// [`ParamSource::QueryParam`]; `@Req()` / `@Res()` becomes
|
||||
/// [`ParamSource::Implicit`].
|
||||
fn refine_with_param_decorators(
|
||||
method_node: Node<'_>,
|
||||
bytes: &[u8],
|
||||
bindings: &mut [ParamBinding],
|
||||
full_path: &str,
|
||||
) {
|
||||
let Some(params) = method_node.child_by_field_name("parameters") else {
|
||||
return;
|
||||
};
|
||||
let mut cur = params.walk();
|
||||
let placeholders = extract_path_placeholders(full_path);
|
||||
let formal_param_nodes: Vec<Node<'_>> = params.named_children(&mut cur).collect();
|
||||
for (idx, formal) in formal_param_nodes.iter().enumerate() {
|
||||
if let Some(refinement) = classify_param_decorator(*formal, bytes, &placeholders)
|
||||
&& let Some(slot) = bindings.get_mut(idx)
|
||||
{
|
||||
slot.source = refinement;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn classify_param_decorator(
|
||||
formal: Node<'_>,
|
||||
bytes: &[u8],
|
||||
placeholders: &[String],
|
||||
) -> Option<ParamSource> {
|
||||
let mut cur = formal.walk();
|
||||
for d in formal.children_by_field_name("decorator", &mut cur) {
|
||||
if let Some(refinement) = decorator_to_param_source(d, bytes, placeholders) {
|
||||
return Some(refinement);
|
||||
}
|
||||
}
|
||||
// Some grammar variants attach the decorator as a preceding
|
||||
// sibling inside the parameter list.
|
||||
let mut prev = formal.prev_named_sibling();
|
||||
while let Some(sib) = prev {
|
||||
if sib.kind() == "decorator" {
|
||||
if let Some(r) = decorator_to_param_source(sib, bytes, placeholders) {
|
||||
return Some(r);
|
||||
}
|
||||
prev = sib.prev_named_sibling();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn decorator_to_param_source(
|
||||
decorator: Node<'_>,
|
||||
bytes: &[u8],
|
||||
placeholders: &[String],
|
||||
) -> Option<ParamSource> {
|
||||
let arg = decorator_first_string_arg(decorator, bytes);
|
||||
if decorator_text_is(decorator, bytes, "Body") {
|
||||
return Some(ParamSource::JsonBody);
|
||||
}
|
||||
if decorator_text_is(decorator, bytes, "Param") {
|
||||
let name = arg.unwrap_or_else(|| {
|
||||
placeholders
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "id".to_owned())
|
||||
});
|
||||
return Some(ParamSource::PathSegment(name));
|
||||
}
|
||||
if decorator_text_is(decorator, bytes, "Query") {
|
||||
let name = arg.unwrap_or_else(|| "q".to_owned());
|
||||
return Some(ParamSource::QueryParam(name));
|
||||
}
|
||||
if decorator_text_is(decorator, bytes, "Headers") {
|
||||
let name = arg.unwrap_or_else(|| "x-nyx".to_owned());
|
||||
return Some(ParamSource::Header(name));
|
||||
}
|
||||
if decorator_text_is(decorator, bytes, "Req")
|
||||
|| decorator_text_is(decorator, bytes, "Res")
|
||||
|| decorator_text_is(decorator, bytes, "Request")
|
||||
|| decorator_text_is(decorator, bytes, "Response")
|
||||
|| decorator_text_is(decorator, bytes, "Next")
|
||||
{
|
||||
return Some(ParamSource::Implicit);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Join a controller prefix and method path segment per Nest's own
|
||||
/// path normalisation: collapse any double-slash run to a single
|
||||
/// slash, ensure the result starts with `/`, and trim a trailing
|
||||
/// slash unless the path is `/` itself.
|
||||
fn join_paths(prefix: &str, sub_path: &str) -> String {
|
||||
let mut combined = String::with_capacity(prefix.len() + sub_path.len() + 2);
|
||||
if !prefix.starts_with('/') {
|
||||
combined.push('/');
|
||||
}
|
||||
combined.push_str(prefix);
|
||||
if !prefix.ends_with('/') && !sub_path.is_empty() && !sub_path.starts_with('/') {
|
||||
combined.push('/');
|
||||
}
|
||||
combined.push_str(sub_path);
|
||||
let collapsed = collapse_slashes(&combined);
|
||||
if collapsed.is_empty() {
|
||||
return "/".to_owned();
|
||||
}
|
||||
collapsed
|
||||
}
|
||||
|
||||
fn collapse_slashes(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut last_was_slash = false;
|
||||
for c in s.chars() {
|
||||
if c == '/' {
|
||||
if !last_was_slash {
|
||||
out.push('/');
|
||||
}
|
||||
last_was_slash = true;
|
||||
} else {
|
||||
out.push(c);
|
||||
last_was_slash = false;
|
||||
}
|
||||
}
|
||||
if out.len() > 1 {
|
||||
while out.ends_with('/') {
|
||||
out.pop();
|
||||
}
|
||||
}
|
||||
if out.is_empty() {
|
||||
return "/".to_owned();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_ts(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang =
|
||||
tree_sitter::Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str, lang: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: lang.into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_slashes_normalises_join() {
|
||||
assert_eq!(join_paths("users", "id"), "/users/id");
|
||||
assert_eq!(join_paths("/users/", "/:id"), "/users/:id");
|
||||
assert_eq!(join_paths("", ""), "/");
|
||||
assert_eq!(join_paths("/", "/"), "/");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_controller_get_decorator() {
|
||||
let src: &[u8] = b"import { Controller, Get, Param } from '@nestjs/common';\n\
|
||||
@Controller('users')\n\
|
||||
export class UsersController {\n\
|
||||
@Get(':id')\n\
|
||||
getUser(@Param('id') id: string) { return id; }\n\
|
||||
}\n";
|
||||
let tree = parse_ts(src);
|
||||
let binding = TsNestAdapter
|
||||
.detect(&summary("getUser", "typescript"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "ts-nest");
|
||||
let route = binding.route.as_ref().unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/users/:id");
|
||||
let id_binding = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "id")
|
||||
.unwrap();
|
||||
assert!(matches!(id_binding.source, ParamSource::PathSegment(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_post_with_body_decorator() {
|
||||
let src: &[u8] = b"import { Controller, Post, Body } from '@nestjs/common';\n\
|
||||
@Controller('items')\n\
|
||||
export class ItemsController {\n\
|
||||
@Post()\n\
|
||||
create(@Body() payload: any) { return payload; }\n\
|
||||
}\n";
|
||||
let tree = parse_ts(src);
|
||||
let binding = TsNestAdapter
|
||||
.detect(&summary("create", "typescript"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(route.path, "/items");
|
||||
let body_binding = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "payload")
|
||||
.unwrap();
|
||||
assert!(matches!(body_binding.source, ParamSource::JsonBody));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_query_decorator() {
|
||||
let src: &[u8] = b"import { Controller, Get, Query } from '@nestjs/common';\n\
|
||||
@Controller()\n\
|
||||
export class SearchController {\n\
|
||||
@Get('search')\n\
|
||||
search(@Query('q') q: string) { return q; }\n\
|
||||
}\n";
|
||||
let tree = parse_ts(src);
|
||||
let binding = TsNestAdapter
|
||||
.detect(&summary("search", "typescript"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.unwrap().path, "/search");
|
||||
let q_binding = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "q")
|
||||
.unwrap();
|
||||
match &q_binding.source {
|
||||
ParamSource::QueryParam(name) => assert_eq!(name, "q"),
|
||||
other => panic!("expected QueryParam, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_not_a_nest_controller() {
|
||||
let src: &[u8] = b"import { Injectable } from '@nestjs/common';\n\
|
||||
@Injectable()\n\
|
||||
export class HelperService {\n\
|
||||
compute(x: number) { return x + 1; }\n\
|
||||
}\n";
|
||||
let tree = parse_ts(src);
|
||||
assert!(TsNestAdapter
|
||||
.detect(&summary("compute", "typescript"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
666
src/dynamic/framework/adapters/js_routes.rs
Normal file
666
src/dynamic/framework/adapters/js_routes.rs
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
//! Shared JS/TS route adapter helpers (Phase 13 — Track L.11).
|
||||
//!
|
||||
//! The Express / Koa / NestJS / Fastify adapters all share a handful of
|
||||
//! tree-sitter helpers: source-import sniffers, formal-name extractors,
|
||||
//! callee-receiver normalisation, path-placeholder extraction, and a
|
||||
//! per-formal binder that promotes `req` / `res` / `ctx` / `next` /
|
||||
//! `reply` to [`ParamSource::Implicit`] and the rest to either
|
||||
//! [`ParamSource::PathSegment`] or [`ParamSource::QueryParam`] depending
|
||||
//! on whether a placeholder of the same name appears in the path
|
||||
//! template.
|
||||
|
||||
use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource};
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// True when `bytes` carries any of the well-known Express import
|
||||
/// stanzas (CommonJS or ESM). Includes router-level imports
|
||||
/// (`express.Router()`) so adapters can fire on files that only pull
|
||||
/// in the router builder.
|
||||
pub fn source_imports_express(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"require('express')",
|
||||
b"require(\"express\")",
|
||||
b"from 'express'",
|
||||
b"from \"express\"",
|
||||
b"express.Router(",
|
||||
b"express.Router()",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `bytes` carries any of the well-known Koa import stanzas.
|
||||
/// Covers Koa itself, `@koa/router`, and `koa-router`.
|
||||
pub fn source_imports_koa(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"require('koa')",
|
||||
b"require(\"koa\")",
|
||||
b"from 'koa'",
|
||||
b"from \"koa\"",
|
||||
b"require('@koa/router')",
|
||||
b"require(\"@koa/router\")",
|
||||
b"from '@koa/router'",
|
||||
b"from \"@koa/router\"",
|
||||
b"require('koa-router')",
|
||||
b"require(\"koa-router\")",
|
||||
b"from 'koa-router'",
|
||||
b"from \"koa-router\"",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `bytes` carries any of the well-known Fastify import
|
||||
/// stanzas.
|
||||
pub fn source_imports_fastify(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"require('fastify')",
|
||||
b"require(\"fastify\")",
|
||||
b"from 'fastify'",
|
||||
b"from \"fastify\"",
|
||||
b"fastify(",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `bytes` carries any of the well-known NestJS import
|
||||
/// stanzas. NestJS is TypeScript-first so the markers include both the
|
||||
/// decorator-import packages and the platform / factory entry points.
|
||||
pub fn source_imports_nest(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"@nestjs/common",
|
||||
b"@nestjs/core",
|
||||
b"@nestjs/platform-express",
|
||||
b"@nestjs/platform-fastify",
|
||||
b"NestFactory",
|
||||
b"@Controller",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
|
||||
needles
|
||||
.iter()
|
||||
.any(|n| haystack.windows(n.len()).any(|w| w == *n))
|
||||
}
|
||||
|
||||
/// Extract the last segment of a member expression chain so
|
||||
/// `app.get` / `router.get` / `fastify.get` all reduce to `"get"`.
|
||||
/// Used by the per-framework adapters to classify the HTTP verb
|
||||
/// regardless of the receiver alias.
|
||||
pub fn last_segment(callee: &str) -> &str {
|
||||
callee.rsplit_once('.').map(|(_, s)| s).unwrap_or(callee)
|
||||
}
|
||||
|
||||
/// Map a route-method name (`get` / `post` / `put` / `patch` /
|
||||
/// `delete` / `options` / `head` / `all`) to an [`HttpMethod`].
|
||||
/// Returns `None` for callees that do not look like an HTTP-verb
|
||||
/// dispatch (so non-route `app.use(handler)` does not fire).
|
||||
pub fn http_verb_from_method(name: &str) -> Option<HttpMethod> {
|
||||
match name.to_ascii_lowercase().as_str() {
|
||||
"get" => Some(HttpMethod::GET),
|
||||
"head" => Some(HttpMethod::HEAD),
|
||||
"post" => Some(HttpMethod::POST),
|
||||
"put" => Some(HttpMethod::PUT),
|
||||
"patch" => Some(HttpMethod::PATCH),
|
||||
"delete" | "del" => Some(HttpMethod::DELETE),
|
||||
"options" => Some(HttpMethod::OPTIONS),
|
||||
// `app.all` registers the handler against every verb — pick
|
||||
// GET as the canonical replay.
|
||||
"all" => Some(HttpMethod::GET),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the surrounding quotes (`'`, `"`, or backticks) from a JS
|
||||
/// string literal node's source text. Returns the inner slice when
|
||||
/// the literal is single-line and unquoted bytes only — multi-line
|
||||
/// template literals fall back to the trimmed input.
|
||||
pub fn strip_quotes(raw: &str) -> &str {
|
||||
let trimmed = raw.trim();
|
||||
if (trimmed.starts_with('\'') && trimmed.ends_with('\''))
|
||||
|| (trimmed.starts_with('"') && trimmed.ends_with('"'))
|
||||
|| (trimmed.starts_with('`') && trimmed.ends_with('`'))
|
||||
{
|
||||
let bytes = trimmed.as_bytes();
|
||||
if bytes.len() >= 2 {
|
||||
return &trimmed[1..trimmed.len() - 1];
|
||||
}
|
||||
}
|
||||
trimmed
|
||||
}
|
||||
|
||||
/// Find a top-level function declaration / function expression /
|
||||
/// arrow function whose binding name equals `target`. Returns the
|
||||
/// `formal_parameters` (or `formal_parameter` for shorthand arrows)
|
||||
/// node so callers can enumerate parameter names.
|
||||
pub fn find_function_params<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
) -> Option<Node<'a>> {
|
||||
let mut hit: Option<Node<'a>> = None;
|
||||
walk_for_params(root, bytes, target, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn walk_for_params<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
out: &mut Option<Node<'a>>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
match node.kind() {
|
||||
"function_declaration" | "generator_function_declaration" => {
|
||||
if let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& name == target
|
||||
&& let Some(params) = node.child_by_field_name("parameters")
|
||||
{
|
||||
*out = Some(params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
"method_definition" => {
|
||||
if let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
&& name == target
|
||||
&& let Some(params) = node.child_by_field_name("parameters")
|
||||
{
|
||||
*out = Some(params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
"variable_declarator" | "assignment_expression" => {
|
||||
// `const name = function() {}`, `const name = (a,b) => ...`,
|
||||
// `name = function() {}`.
|
||||
let name_field = if node.kind() == "variable_declarator" {
|
||||
"name"
|
||||
} else {
|
||||
"left"
|
||||
};
|
||||
if let Some(name_node) = node.child_by_field_name(name_field)
|
||||
&& let Some(name) = name_node.utf8_text(bytes).ok()
|
||||
&& name == target
|
||||
&& let Some(value) = node.child_by_field_name("value").or_else(|| {
|
||||
if node.kind() == "assignment_expression" {
|
||||
node.child_by_field_name("right")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
{
|
||||
match value.kind() {
|
||||
"function_expression"
|
||||
| "function"
|
||||
| "arrow_function"
|
||||
| "generator_function" => {
|
||||
if let Some(params) = value.child_by_field_name("parameters") {
|
||||
*out = Some(params);
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_for_params(child, bytes, target, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate identifier names from a `formal_parameters` node. Skips
|
||||
/// the rest-element marker (`...`) and any destructuring wrappers so
|
||||
/// the returned vector lines up with positional ordering of declared
|
||||
/// parameters.
|
||||
pub fn function_formal_names(params: Node<'_>, bytes: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = params.walk();
|
||||
for child in params.named_children(&mut cur) {
|
||||
if let Some(name) = parameter_name(child, bytes) {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parameter_name(node: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
match node.kind() {
|
||||
"identifier" | "shorthand_property_identifier_pattern" => {
|
||||
node.utf8_text(bytes).ok().map(str::to_owned)
|
||||
}
|
||||
"assignment_pattern" | "required_parameter" | "optional_parameter" => {
|
||||
// `x = 1` / TypeScript `x: T` / `x?: T`
|
||||
if let Some(left) = node.child_by_field_name("left") {
|
||||
return parameter_name(left, bytes);
|
||||
}
|
||||
if let Some(pattern) = node.child_by_field_name("pattern") {
|
||||
return parameter_name(pattern, bytes);
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for c in node.named_children(&mut cur) {
|
||||
if c.kind() == "identifier" {
|
||||
return c.utf8_text(bytes).ok().map(str::to_owned);
|
||||
}
|
||||
if let Some(n) = parameter_name(c, bytes) {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
"rest_pattern" | "object_pattern" | "array_pattern" => {
|
||||
let mut cur = node.walk();
|
||||
for c in node.named_children(&mut cur) {
|
||||
if let Some(n) = parameter_name(c, bytes) {
|
||||
return Some(n);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bind formals to request slots given a route path template.
|
||||
///
|
||||
/// Accepts three placeholder syntaxes simultaneously: Express /
|
||||
/// Fastify `:id`, FastAPI / Starlette `{id}`, and Hapi-style
|
||||
/// `{id?}`. A formal whose name matches a placeholder becomes a
|
||||
/// [`ParamSource::PathSegment`]; the well-known framework context
|
||||
/// formals (`req` / `request` / `res` / `response` / `reply` /
|
||||
/// `ctx` / `context` / `next`) become
|
||||
/// [`ParamSource::Implicit`]; everything else falls back to
|
||||
/// [`ParamSource::QueryParam`] so downstream harness emitters have
|
||||
/// a deterministic slot to populate.
|
||||
pub fn bind_path_params(formals: &[String], path: &str) -> Vec<ParamBinding> {
|
||||
let placeholders = extract_path_placeholders(path);
|
||||
formals
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, name)| {
|
||||
let source = if is_implicit_formal(name) {
|
||||
ParamSource::Implicit
|
||||
} else if placeholders.iter().any(|p| p == name) {
|
||||
ParamSource::PathSegment(name.clone())
|
||||
} else {
|
||||
ParamSource::QueryParam(name.clone())
|
||||
};
|
||||
ParamBinding {
|
||||
index: idx,
|
||||
name: name.clone(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_implicit_formal(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"req"
|
||||
| "request"
|
||||
| "res"
|
||||
| "response"
|
||||
| "reply"
|
||||
| "ctx"
|
||||
| "context"
|
||||
| "next"
|
||||
| "done"
|
||||
)
|
||||
}
|
||||
|
||||
/// Extract placeholder names from a route path template.
|
||||
///
|
||||
/// Supports three placeholder syntaxes:
|
||||
/// - Express / Fastify / NestJS: `/users/:id` → `id`,
|
||||
/// `/users/:id(\\d+)` → `id` (anything inside `()` is dropped).
|
||||
/// - FastAPI / Starlette mirrors: `/users/{id}` → `id`.
|
||||
/// - Hapi-style optional: `/users/{id?}` → `id`.
|
||||
///
|
||||
/// Names are deduplicated while preserving first-occurrence order so a
|
||||
/// single placeholder reused across the path does not double-bind a
|
||||
/// formal.
|
||||
pub fn extract_path_placeholders(path: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |name: String| {
|
||||
let trimmed = name.trim_end_matches(['?', '*']).to_owned();
|
||||
if !trimmed.is_empty() && !out.iter().any(|n| n == &trimmed) {
|
||||
out.push(trimmed);
|
||||
}
|
||||
};
|
||||
let bytes = path.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b':' => {
|
||||
let start = i + 1;
|
||||
let mut j = start;
|
||||
while j < bytes.len()
|
||||
&& (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_')
|
||||
{
|
||||
j += 1;
|
||||
}
|
||||
if j > start {
|
||||
push(path[start..j].to_owned());
|
||||
}
|
||||
// Skip a parenthesised regex constraint like `:id(\\d+)`.
|
||||
if j < bytes.len() && bytes[j] == b'(' {
|
||||
let mut depth = 1usize;
|
||||
j += 1;
|
||||
while j < bytes.len() && depth > 0 {
|
||||
match bytes[j] {
|
||||
b'(' => depth += 1,
|
||||
b')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
}
|
||||
i = j;
|
||||
continue;
|
||||
}
|
||||
b'{' => {
|
||||
if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') {
|
||||
let inner = &path[i + 1..i + 1 + end];
|
||||
let name = inner.split(':').next().unwrap_or(inner);
|
||||
push(name.to_owned());
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// True when `view_arg` references `target` either directly
|
||||
/// (`handler`) or as a member expression whose last segment is
|
||||
/// `target` (`controller.handler` / `module.exports.handler`).
|
||||
pub fn view_arg_references(view_arg: Node<'_>, bytes: &[u8], target: &str) -> bool {
|
||||
match view_arg.kind() {
|
||||
"identifier" => view_arg
|
||||
.utf8_text(bytes)
|
||||
.ok()
|
||||
.map(|t| t == target)
|
||||
.unwrap_or(false),
|
||||
"member_expression" => view_arg
|
||||
.utf8_text(bytes)
|
||||
.ok()
|
||||
.map(|t| last_segment(t) == target)
|
||||
.unwrap_or(false),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk `root` searching for a call expression `<receiver>.<verb>(<path>, ..., <handler>)`
|
||||
/// or `<receiver>.<verb>({ method, url, handler })` (Fastify-style
|
||||
/// options-object). When the callee is one of the well-known HTTP
|
||||
/// verbs, the receiver name is accepted by `receiver_accepts`, and one
|
||||
/// of the positional arguments references `target`, returns the
|
||||
/// `(method, path)` pair extracted from the first positional string
|
||||
/// argument.
|
||||
///
|
||||
/// The receiver check uses a closure so each per-framework adapter
|
||||
/// can accept its own canonical aliases (`app` / `router` for Express,
|
||||
/// `fastify` / `server` for Fastify, etc.) without re-walking the
|
||||
/// AST. The handler position is permissive: any positional arg whose
|
||||
/// identifier matches `target` (or whose last member-expression segment
|
||||
/// matches) is accepted, so middleware-chained registrations
|
||||
/// (`app.get('/x', authz, handler)`) bind correctly.
|
||||
pub fn find_route_registration<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
receiver_accepts: &dyn Fn(&str) -> bool,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut hit: Option<(HttpMethod, String)> = None;
|
||||
walk_for_registration(root, bytes, target, receiver_accepts, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn walk_for_registration<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
receiver_accepts: &dyn Fn(&str) -> bool,
|
||||
out: &mut Option<(HttpMethod, String)>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "call_expression"
|
||||
&& let Some(callee) = node.child_by_field_name("function")
|
||||
&& callee.kind() == "member_expression"
|
||||
&& let Some(object) = callee.child_by_field_name("object")
|
||||
&& let Some(property) = callee.child_by_field_name("property")
|
||||
&& let Some(object_text) = object.utf8_text(bytes).ok()
|
||||
&& let Some(prop_text) = property.utf8_text(bytes).ok()
|
||||
{
|
||||
if let Some(method) = http_verb_from_method(prop_text)
|
||||
&& receiver_accepts(last_segment(object_text))
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
{
|
||||
if call_args_reference_target(args, bytes, target) {
|
||||
if let Some(path) = first_string_arg(args, bytes) {
|
||||
*out = Some((method, path));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fastify options-object: `fastify.route({ method, url, handler })`.
|
||||
if prop_text == "route"
|
||||
&& receiver_accepts(last_segment(object_text))
|
||||
&& let Some(args) = node.child_by_field_name("arguments")
|
||||
&& let Some((method, path)) = parse_options_route(args, bytes, target)
|
||||
{
|
||||
*out = Some((method, path));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_for_registration(child, bytes, target, receiver_accepts, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// True when any positional argument in `args` references `target` —
|
||||
/// either as a bare identifier or as the last segment of a
|
||||
/// `member_expression`. Skips object literals (Fastify's options-form
|
||||
/// is matched separately by [`parse_options_route`]).
|
||||
fn call_args_reference_target(args: Node<'_>, bytes: &[u8], target: &str) -> bool {
|
||||
let mut cur = args.walk();
|
||||
for c in args.named_children(&mut cur) {
|
||||
if view_arg_references(c, bytes, target) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Find the first positional string-literal argument in an
|
||||
/// `arguments` node. Returns the literal's inner text with the
|
||||
/// surrounding quotes stripped.
|
||||
pub fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
let mut cur = args.walk();
|
||||
for c in args.named_children(&mut cur) {
|
||||
if c.kind() == "string" || c.kind() == "template_string" {
|
||||
let raw = c.utf8_text(bytes).ok()?;
|
||||
return Some(strip_quotes(raw).to_owned());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a Fastify options-object call `fastify.route({ method, url,
|
||||
/// handler })` returning the bound `(method, url)` when the
|
||||
/// `handler:` property references `target`.
|
||||
fn parse_options_route(
|
||||
args: Node<'_>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut cur = args.walk();
|
||||
for c in args.named_children(&mut cur) {
|
||||
if c.kind() != "object" {
|
||||
continue;
|
||||
}
|
||||
let mut method: Option<HttpMethod> = None;
|
||||
let mut url: Option<String> = None;
|
||||
let mut handler_matches = false;
|
||||
let mut oc = c.walk();
|
||||
for pair in c.named_children(&mut oc) {
|
||||
if pair.kind() != "pair" {
|
||||
continue;
|
||||
}
|
||||
let Some(key) = pair.child_by_field_name("key").and_then(|n| n.utf8_text(bytes).ok())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(value) = pair.child_by_field_name("value") else {
|
||||
continue;
|
||||
};
|
||||
let key = key.trim_matches(['\'', '"', '`']);
|
||||
match key {
|
||||
"method" => {
|
||||
let text = value.utf8_text(bytes).ok().unwrap_or("");
|
||||
method = http_verb_from_method(strip_quotes(text));
|
||||
}
|
||||
"url" | "path" => {
|
||||
let text = value.utf8_text(bytes).ok().unwrap_or("");
|
||||
url = Some(strip_quotes(text).to_owned());
|
||||
}
|
||||
"handler" => {
|
||||
if view_arg_references(value, bytes, target) {
|
||||
handler_matches = true;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if handler_matches
|
||||
&& let Some(m) = method
|
||||
&& let Some(u) = url
|
||||
{
|
||||
return Some((m, u));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_express_placeholders() {
|
||||
assert_eq!(extract_path_placeholders("/users/:id"), vec!["id"]);
|
||||
assert_eq!(
|
||||
extract_path_placeholders("/u/:id/posts/:slug"),
|
||||
vec!["id", "slug"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_brace_placeholders() {
|
||||
assert_eq!(extract_path_placeholders("/users/{id}"), vec!["id"]);
|
||||
assert_eq!(extract_path_placeholders("/users/{id?}"), vec!["id"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn last_segment_strips_receiver() {
|
||||
assert_eq!(last_segment("app.get"), "get");
|
||||
assert_eq!(last_segment("router.api.post"), "post");
|
||||
assert_eq!(last_segment("get"), "get");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verb_dispatch_handles_aliases() {
|
||||
assert_eq!(http_verb_from_method("GET"), Some(HttpMethod::GET));
|
||||
assert_eq!(http_verb_from_method("del"), Some(HttpMethod::DELETE));
|
||||
assert_eq!(http_verb_from_method("use"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_function_declaration_params() {
|
||||
let src: &[u8] = b"function handler(req, res) {}\n";
|
||||
let tree = parse_js(src);
|
||||
let params = find_function_params(tree.root_node(), src, "handler").unwrap();
|
||||
let names = function_formal_names(params, src);
|
||||
assert_eq!(names, vec!["req", "res"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_const_arrow_params() {
|
||||
let src: &[u8] = b"const handler = (req, res, next) => {};\n";
|
||||
let tree = parse_js(src);
|
||||
let params = find_function_params(tree.root_node(), src, "handler").unwrap();
|
||||
let names = function_formal_names(params, src);
|
||||
assert_eq!(names, vec!["req", "res", "next"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_path_params_marks_implicit() {
|
||||
let formals = vec!["req".to_owned(), "res".to_owned(), "next".to_owned()];
|
||||
let bound = bind_path_params(&formals, "/x");
|
||||
for b in &bound {
|
||||
assert!(matches!(b.source, ParamSource::Implicit));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_route_registration_matches_app_get() {
|
||||
let src: &[u8] = b"app.get('/users/:id', handler);\n";
|
||||
let tree = parse_js(src);
|
||||
let recv = |n: &str| n == "app";
|
||||
let (method, path) =
|
||||
find_route_registration(tree.root_node(), src, "handler", &recv).unwrap();
|
||||
assert_eq!(method, HttpMethod::GET);
|
||||
assert_eq!(path, "/users/:id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_route_registration_matches_middleware_chain() {
|
||||
let src: &[u8] = b"app.post('/save', authz, validate, handler);\n";
|
||||
let tree = parse_js(src);
|
||||
let recv = |n: &str| n == "app";
|
||||
let (method, path) =
|
||||
find_route_registration(tree.root_node(), src, "handler", &recv).unwrap();
|
||||
assert_eq!(method, HttpMethod::POST);
|
||||
assert_eq!(path, "/save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_route_registration_matches_fastify_options_object() {
|
||||
let src: &[u8] =
|
||||
b"fastify.route({ method: 'PUT', url: '/users/:id', handler: handler });\n";
|
||||
let tree = parse_js(src);
|
||||
let recv = |n: &str| n == "fastify";
|
||||
let (method, path) =
|
||||
find_route_registration(tree.root_node(), src, "handler", &recv).unwrap();
|
||||
assert_eq!(method, HttpMethod::PUT);
|
||||
assert_eq!(path, "/users/:id");
|
||||
}
|
||||
}
|
||||
|
|
@ -20,7 +20,12 @@ pub mod header_ruby;
|
|||
pub mod header_rust;
|
||||
pub mod java_deserialize;
|
||||
pub mod java_thymeleaf;
|
||||
pub mod js_express;
|
||||
pub mod js_fastify;
|
||||
pub mod js_handlebars;
|
||||
pub mod js_koa;
|
||||
pub mod js_nest;
|
||||
pub mod js_routes;
|
||||
pub mod ldap_php;
|
||||
pub mod ldap_python;
|
||||
pub mod ldap_spring;
|
||||
|
|
@ -64,7 +69,11 @@ pub use header_ruby::HeaderRubyAdapter;
|
|||
pub use header_rust::HeaderRustAdapter;
|
||||
pub use java_deserialize::JavaDeserializeAdapter;
|
||||
pub use java_thymeleaf::JavaThymeleafAdapter;
|
||||
pub use js_express::JsExpressAdapter;
|
||||
pub use js_fastify::JsFastifyAdapter;
|
||||
pub use js_handlebars::JsHandlebarsAdapter;
|
||||
pub use js_koa::JsKoaAdapter;
|
||||
pub use js_nest::{JsNestAdapter, TsNestAdapter};
|
||||
pub use ldap_php::LdapPhpAdapter;
|
||||
pub use ldap_python::LdapPythonAdapter;
|
||||
pub use ldap_spring::LdapSpringAdapter;
|
||||
|
|
|
|||
|
|
@ -214,13 +214,14 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn registry_baseline_after_phase_12() {
|
||||
// Phase 12 (Track L.10) adds four Python framework adapters
|
||||
// (`python-django`, `python-fastapi`, `python-flask`,
|
||||
// `python-starlette`) to the Python slice, growing it from
|
||||
// 7 → 11. Java / PHP keep their 7-entry J.1..J.7 stacks;
|
||||
// Ruby keeps 5; Go keeps 3; Rust keeps 2; JavaScript keeps 7;
|
||||
// TypeScript keeps 3. C / Cpp stay empty.
|
||||
fn registry_baseline_after_phase_13() {
|
||||
// Phase 13 (Track L.11) adds four JS framework adapters
|
||||
// (`js-express`, `js-fastify`, `js-koa`, `js-nest`) to the
|
||||
// JavaScript slice, growing it from 7 → 11; the TypeScript
|
||||
// slice gains `ts-nest`, growing it from 3 → 4. Phase 12
|
||||
// (Track L.10) baseline for Python / Java / Php / Ruby / Go /
|
||||
// Rust remains unchanged: Python 11, Java 7, Php 7, Ruby 5,
|
||||
// Go 3, Rust 2. C / Cpp stay empty.
|
||||
for lang in [Lang::Java, Lang::Php] {
|
||||
let registered = registry::adapters_for(lang);
|
||||
assert_eq!(
|
||||
|
|
@ -254,8 +255,8 @@ mod tests {
|
|||
let js_registered = registry::adapters_for(Lang::JavaScript);
|
||||
assert_eq!(
|
||||
js_registered.len(),
|
||||
7,
|
||||
"JavaScript must have J.2 + J.5 + J.6 + J.7 + J.8(×3) adapters",
|
||||
11,
|
||||
"JavaScript must have J.2 + J.5 + J.6 + J.7 + J.8(×3) + L.11(×4) adapters",
|
||||
);
|
||||
for adapter in js_registered {
|
||||
assert_eq!(adapter.lang(), Lang::JavaScript);
|
||||
|
|
@ -263,8 +264,8 @@ mod tests {
|
|||
let ts_registered = registry::adapters_for(Lang::TypeScript);
|
||||
assert_eq!(
|
||||
ts_registered.len(),
|
||||
3,
|
||||
"TypeScript must have the J.8(×3) prototype-pollution adapters",
|
||||
4,
|
||||
"TypeScript must have the J.8(×3) prototype-pollution adapters + L.11 ts-nest",
|
||||
);
|
||||
for adapter in ts_registered {
|
||||
assert_eq!(adapter.lang(), Lang::TypeScript);
|
||||
|
|
|
|||
|
|
@ -97,10 +97,15 @@ static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[
|
|||
&super::adapters::PpJsonDeepAssignTsAdapter,
|
||||
&super::adapters::PpLodashMergeTsAdapter,
|
||||
&super::adapters::PpObjectAssignTsAdapter,
|
||||
&super::adapters::TsNestAdapter,
|
||||
];
|
||||
static JAVASCRIPT: &[&dyn FrameworkAdapter] = &[
|
||||
&super::adapters::HeaderJsAdapter,
|
||||
&super::adapters::JsExpressAdapter,
|
||||
&super::adapters::JsFastifyAdapter,
|
||||
&super::adapters::JsHandlebarsAdapter,
|
||||
&super::adapters::JsKoaAdapter,
|
||||
&super::adapters::JsNestAdapter,
|
||||
&super::adapters::PpJsonDeepAssignJsAdapter,
|
||||
&super::adapters::PpLodashMergeJsAdapter,
|
||||
&super::adapters::PpObjectAssignJsAdapter,
|
||||
|
|
|
|||
|
|
@ -54,6 +54,19 @@ pub enum JsShape {
|
|||
/// DOM event handler executed inside a `jsdom` window. Harness sets
|
||||
/// up `globalThis.window` / `document` and dispatches an event.
|
||||
BrowserEvent,
|
||||
/// Fastify route plugin. Harness loads the entry's `app` export
|
||||
/// (which must be a configured Fastify instance) and replays the
|
||||
/// spec's request through Fastify's built-in
|
||||
/// [`light-my-request`](https://github.com/fastify/light-my-request)
|
||||
/// equivalent — `app.inject({ method, url, query, payload, headers })`.
|
||||
/// No external `supertest` dep is required because `inject` ships in
|
||||
/// Fastify core. Phase 13 — Track L.11.
|
||||
Fastify,
|
||||
/// NestJS controller class. Harness loads the entry's exported
|
||||
/// controller class, mounts it via `Test.createTestingModule`, and
|
||||
/// replays the spec's request through `supertest(app.getHttpServer())`.
|
||||
/// Phase 13 — Track L.11.
|
||||
Nest,
|
||||
}
|
||||
|
||||
impl JsShape {
|
||||
|
|
@ -72,6 +85,28 @@ impl JsShape {
|
|||
source,
|
||||
&["require('koa')", "require(\"koa\")", "from 'koa'", "from \"koa\""],
|
||||
);
|
||||
let has_fastify = source_has_marker(
|
||||
source,
|
||||
&[
|
||||
"require('fastify')",
|
||||
"require(\"fastify\")",
|
||||
"from 'fastify'",
|
||||
"from \"fastify\"",
|
||||
"// nyx-shape: fastify",
|
||||
],
|
||||
);
|
||||
let has_nest = source_has_marker(
|
||||
source,
|
||||
&[
|
||||
"@nestjs/common",
|
||||
"@nestjs/core",
|
||||
"@nestjs/platform-express",
|
||||
"@nestjs/platform-fastify",
|
||||
"NestFactory",
|
||||
"@Controller",
|
||||
"// nyx-shape: nest",
|
||||
],
|
||||
);
|
||||
let has_next = source_has_marker(
|
||||
source,
|
||||
&["from 'next'", "from \"next\"", "NextApiRequest", "NextApiResponse", "// nyx-shape: next"],
|
||||
|
|
@ -97,6 +132,16 @@ impl JsShape {
|
|||
&["export default ", "// nyx-shape: esm-default"],
|
||||
);
|
||||
|
||||
// Nest wins over Express / Fastify because Nest projects also
|
||||
// import `@nestjs/platform-express` / `@nestjs/platform-fastify`
|
||||
// transitively — the controller-class shape needs its own
|
||||
// testing module bootstrap.
|
||||
if has_nest {
|
||||
return Self::Nest;
|
||||
}
|
||||
if has_fastify {
|
||||
return Self::Fastify;
|
||||
}
|
||||
if has_express {
|
||||
return Self::Express;
|
||||
}
|
||||
|
|
@ -402,6 +447,31 @@ fn extra_files_for_shape(shape: JsShape) -> Vec<(String, String)> {
|
|||
("package.json".to_owned(), package_json_for("jsdom", "^24.1.1")),
|
||||
("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-jsdom")),
|
||||
],
|
||||
JsShape::Fastify => vec![
|
||||
("package.json".to_owned(), package_json_for("fastify", "^4.28.1")),
|
||||
("package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-fastify")),
|
||||
],
|
||||
JsShape::Nest => vec![
|
||||
(
|
||||
"package.json".to_owned(),
|
||||
package_json_multi(
|
||||
"nyx-harness-nest",
|
||||
&[
|
||||
("@nestjs/common", "^10.0.0"),
|
||||
("@nestjs/core", "^10.0.0"),
|
||||
("@nestjs/platform-express", "^10.0.0"),
|
||||
("@nestjs/testing", "^10.0.0"),
|
||||
("supertest", "^7.0.0"),
|
||||
("reflect-metadata", "^0.2.0"),
|
||||
("rxjs", "^7.8.0"),
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
"package-lock.json".to_owned(),
|
||||
package_lock_skeleton("nyx-harness-nest"),
|
||||
),
|
||||
],
|
||||
// Plain async / CJS / ESM use stdlib only.
|
||||
_ => vec![],
|
||||
}
|
||||
|
|
@ -413,6 +483,26 @@ fn package_json_for(dep: &str, version: &str) -> String {
|
|||
)
|
||||
}
|
||||
|
||||
fn package_json_multi(pkg_name: &str, deps: &[(&str, &str)]) -> String {
|
||||
let mut body = String::with_capacity(128);
|
||||
body.push_str("{\n \"name\": \"");
|
||||
body.push_str(pkg_name);
|
||||
body.push_str("\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"dependencies\": {\n");
|
||||
for (i, (name, ver)) in deps.iter().enumerate() {
|
||||
body.push_str(" \"");
|
||||
body.push_str(name);
|
||||
body.push_str("\": \"");
|
||||
body.push_str(ver);
|
||||
body.push('"');
|
||||
if i + 1 != deps.len() {
|
||||
body.push(',');
|
||||
}
|
||||
body.push('\n');
|
||||
}
|
||||
body.push_str(" }\n}\n");
|
||||
body
|
||||
}
|
||||
|
||||
fn package_lock_skeleton(name: &str) -> String {
|
||||
// Bare lockfile structure. npm rewrites this on first install; checking
|
||||
// it in keeps the per-shape fixture directory self-describing.
|
||||
|
|
@ -980,6 +1070,8 @@ fn generate_for_shape(spec: &HarnessSpec, shape: JsShape, entry_subpath: &str) -
|
|||
JsShape::Koa => emit_koa(spec),
|
||||
JsShape::NextRoute => emit_next(spec),
|
||||
JsShape::BrowserEvent => emit_browser_event(spec),
|
||||
JsShape::Fastify => emit_fastify(spec),
|
||||
JsShape::Nest => emit_nest(spec),
|
||||
};
|
||||
format!("{preamble}\n{body}\n")
|
||||
}
|
||||
|
|
@ -1260,6 +1352,140 @@ const _res = {{
|
|||
)
|
||||
}
|
||||
|
||||
/// Phase 13 — Track L.11 Fastify harness.
|
||||
///
|
||||
/// Loads the entry's `app` export (the configured Fastify instance)
|
||||
/// and replays the spec's request through Fastify's built-in
|
||||
/// [`light-my-request`](https://github.com/fastify/light-my-request)
|
||||
/// equivalent — `app.inject({ method, url, query, payload, headers })`.
|
||||
/// No external `supertest` dep is required because `inject` ships in
|
||||
/// Fastify core.
|
||||
fn emit_fastify(spec: &HarnessSpec) -> String {
|
||||
let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot);
|
||||
format!(
|
||||
r#"// Shape: Fastify route — boot via app.inject() (light-my-request equivalent).
|
||||
const _app = _entry.app || _entry.default || _entry;
|
||||
if (!_app || typeof _app.inject !== 'function') {{
|
||||
process.stderr.write('NYX_FASTIFY_APP_NOT_FOUND\n');
|
||||
process.exit(78);
|
||||
}}
|
||||
const _kind = {body_kind:?};
|
||||
const _payload_key = {payload_key:?};
|
||||
const _method = {method:?};
|
||||
let _path = '/';
|
||||
let _query;
|
||||
let _bodyArg = undefined;
|
||||
let _headers = {{}};
|
||||
if (_kind === 'query') {{
|
||||
_query = {{}};
|
||||
_query[_payload_key] = payload;
|
||||
}} else if (_kind === 'body') {{
|
||||
_bodyArg = payload;
|
||||
_headers['content-type'] = 'application/json';
|
||||
}} else if (_kind === 'env') {{
|
||||
process.env[_payload_key] = payload;
|
||||
}} else if (_kind === 'param') {{
|
||||
_path = '/' + encodeURIComponent(payload);
|
||||
}}
|
||||
(async () => {{
|
||||
try {{
|
||||
if (typeof _app.ready === 'function') await _app.ready();
|
||||
const _injectOpts = {{ method: _method, url: _path, headers: _headers }};
|
||||
if (_query) _injectOpts.query = _query;
|
||||
if (_bodyArg !== undefined) _injectOpts.payload = _bodyArg;
|
||||
const _res = await _app.inject(_injectOpts);
|
||||
process.stdout.write(String(_res.body == null ? '' : _res.body) + '\n');
|
||||
}} catch (e) {{
|
||||
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
|
||||
}}
|
||||
}})();
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Phase 13 — Track L.11 NestJS harness.
|
||||
///
|
||||
/// Loads the entry's exported controller class (`_entry.Controller`
|
||||
/// / `_entry.default`), mounts it via
|
||||
/// `Test.createTestingModule({controllers:[Controller]}).compile()`,
|
||||
/// boots the Nest application, and replays the spec's request through
|
||||
/// `supertest(app.getHttpServer())`. Falls back to `_entry.app`
|
||||
/// (already-built Nest app instance) when the fixture pre-mounts
|
||||
/// itself. The `supertest` dep is bundled by `extra_files_for_shape`.
|
||||
fn emit_nest(spec: &HarnessSpec) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let (method, payload_key, body_kind) = resolve_http_payload(&spec.payload_slot);
|
||||
let method_lower = method.to_ascii_lowercase();
|
||||
format!(
|
||||
r#"// Shape: NestJS controller — boot via Test.createTestingModule + supertest.
|
||||
require('reflect-metadata');
|
||||
let _supertest;
|
||||
try {{
|
||||
_supertest = require('supertest');
|
||||
}} catch (e) {{
|
||||
process.stderr.write('NYX_SUPERTEST_MISSING: ' + e.message + '\n');
|
||||
process.exit(79);
|
||||
}}
|
||||
let _NestTesting;
|
||||
try {{
|
||||
_NestTesting = require('@nestjs/testing');
|
||||
}} catch (e) {{
|
||||
process.stderr.write('NYX_NESTJS_TESTING_MISSING: ' + e.message + '\n');
|
||||
process.exit(79);
|
||||
}}
|
||||
const _kind = {body_kind:?};
|
||||
const _payload_key = {payload_key:?};
|
||||
const _method_lc = {method_lower:?};
|
||||
const _entry_name = {entry_fn:?};
|
||||
let _path = '/';
|
||||
if (_kind === 'env') {{
|
||||
process.env[_payload_key] = payload;
|
||||
}} else if (_kind === 'param') {{
|
||||
_path = '/' + encodeURIComponent(payload);
|
||||
}}
|
||||
(async () => {{
|
||||
try {{
|
||||
let _app = _entry.app || (_entry.default && _entry.default.app);
|
||||
if (!_app) {{
|
||||
// Locate a controller class — first @Controller / class export.
|
||||
const _candidate = _entry[_entry_name]
|
||||
|| _entry.default
|
||||
|| _entry.AppController
|
||||
|| _entry.Controller
|
||||
|| Object.values(_entry).find((v) => typeof v === 'function');
|
||||
if (typeof _candidate !== 'function') {{
|
||||
process.stderr.write('NYX_NEST_CONTROLLER_NOT_FOUND\n');
|
||||
process.exit(78);
|
||||
}}
|
||||
const _module = await _NestTesting.Test
|
||||
.createTestingModule({{ controllers: [_candidate] }})
|
||||
.compile();
|
||||
_app = _module.createNestApplication();
|
||||
await _app.init();
|
||||
}}
|
||||
const _server = (typeof _app.getHttpServer === 'function')
|
||||
? _app.getHttpServer()
|
||||
: _app;
|
||||
const _agent = _supertest(_server);
|
||||
let _req = _agent[_method_lc](_path);
|
||||
if (_kind === 'query') {{
|
||||
const _q = {{}};
|
||||
_q[_payload_key] = payload;
|
||||
_req = _req.query(_q);
|
||||
}} else if (_kind === 'body') {{
|
||||
_req = _req.set('content-type', 'application/json').send(payload);
|
||||
}}
|
||||
const _res = await _req;
|
||||
process.stdout.write(String(_res.text == null ? '' : _res.text) + '\n');
|
||||
if (typeof _app.close === 'function') await _app.close();
|
||||
}} catch (e) {{
|
||||
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
|
||||
}}
|
||||
}})();
|
||||
"#
|
||||
)
|
||||
}
|
||||
|
||||
fn emit_browser_event(spec: &HarnessSpec) -> String {
|
||||
let entry_fn = &spec.entry_name;
|
||||
let (pre_call, call_args) = build_call_args(spec);
|
||||
|
|
|
|||
28
tests/dynamic_fixtures/js_frameworks/express/benign.js
Normal file
28
tests/dynamic_fixtures/js_frameworks/express/benign.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Phase 13 (Track L.11) — Express CMDI benign fixture.
|
||||
//
|
||||
// The `/run` route accepts a `cmd` query parameter but rejects
|
||||
// everything outside an allowlist before invoking `child_process.exec`
|
||||
// with a fixed argv, so the sink call is unreachable for
|
||||
// attacker-controlled values.
|
||||
|
||||
const express = require('express');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const app = express();
|
||||
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
function runCmd(req, res) {
|
||||
const cmd = req.query.cmd || '';
|
||||
if (!ALLOW.has(cmd)) {
|
||||
return res.status(400).send('rejected');
|
||||
}
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
if (err) return res.status(500).send(String(err));
|
||||
res.send(stdout);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/run', runCmd);
|
||||
|
||||
module.exports = { app, runCmd };
|
||||
23
tests/dynamic_fixtures/js_frameworks/express/vuln.js
Normal file
23
tests/dynamic_fixtures/js_frameworks/express/vuln.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Phase 13 (Track L.11) — Express CMDI vuln fixture.
|
||||
//
|
||||
// The `/run` route forwards a `cmd` query parameter straight into
|
||||
// `child_process.exec`, so any attacker who reaches the route can
|
||||
// execute arbitrary shell. Adapter binding:
|
||||
// `app.get('/run', runCmd)` with `cmd` flowing through `req.query.cmd`.
|
||||
|
||||
const express = require('express');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
const app = express();
|
||||
|
||||
function runCmd(req, res) {
|
||||
const cmd = req.query.cmd || '';
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err) return res.status(500).send(String(err));
|
||||
res.send(stdout);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/run', runCmd);
|
||||
|
||||
module.exports = { app, runCmd };
|
||||
28
tests/dynamic_fixtures/js_frameworks/fastify/benign.js
Normal file
28
tests/dynamic_fixtures/js_frameworks/fastify/benign.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Phase 13 (Track L.11) — Fastify CMDI benign fixture.
|
||||
//
|
||||
// The `/run` route accepts a `cmd` query parameter but rejects
|
||||
// everything outside an allowlist before invoking
|
||||
// `child_process.execFile` with a fixed argv.
|
||||
|
||||
const fastify = require('fastify')();
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
async function runCmd(request, reply) {
|
||||
const cmd = request.query.cmd || '';
|
||||
if (!ALLOW.has(cmd)) {
|
||||
reply.code(400).send('rejected');
|
||||
return;
|
||||
}
|
||||
const out = await new Promise((resolve) => {
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
resolve(err ? String(err) : stdout);
|
||||
});
|
||||
});
|
||||
reply.send(out);
|
||||
}
|
||||
|
||||
fastify.get('/run', runCmd);
|
||||
|
||||
module.exports = { app: fastify, runCmd };
|
||||
20
tests/dynamic_fixtures/js_frameworks/fastify/vuln.js
Normal file
20
tests/dynamic_fixtures/js_frameworks/fastify/vuln.js
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Phase 13 (Track L.11) — Fastify CMDI vuln fixture.
|
||||
//
|
||||
// The `/run` route forwards a `cmd` query parameter straight into
|
||||
// `child_process.exec`. Adapter binding: `fastify.get('/run', runCmd)`
|
||||
// with `cmd` flowing through `request.query.cmd`.
|
||||
|
||||
const fastify = require('fastify')();
|
||||
const { exec } = require('child_process');
|
||||
|
||||
async function runCmd(request, reply) {
|
||||
const cmd = request.query.cmd || '';
|
||||
const out = await new Promise((resolve) => {
|
||||
exec(cmd, (err, stdout) => resolve(err ? String(err) : stdout));
|
||||
});
|
||||
reply.send(out);
|
||||
}
|
||||
|
||||
fastify.get('/run', runCmd);
|
||||
|
||||
module.exports = { app: fastify, runCmd };
|
||||
34
tests/dynamic_fixtures/js_frameworks/koa/benign.js
Normal file
34
tests/dynamic_fixtures/js_frameworks/koa/benign.js
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Phase 13 (Track L.11) — Koa CMDI benign fixture.
|
||||
//
|
||||
// The `/run` route accepts a `cmd` query parameter but rejects
|
||||
// everything outside an allowlist before invoking `child_process.execFile`
|
||||
// with a fixed argv.
|
||||
|
||||
const Koa = require('koa');
|
||||
const Router = require('@koa/router');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
async function runCmd(ctx) {
|
||||
const cmd = ctx.query.cmd || '';
|
||||
if (!ALLOW.has(cmd)) {
|
||||
ctx.status = 400;
|
||||
ctx.body = 'rejected';
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
ctx.body = err ? String(err) : stdout;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/run', runCmd);
|
||||
app.use(router.routes());
|
||||
|
||||
module.exports = { app, runCmd };
|
||||
27
tests/dynamic_fixtures/js_frameworks/koa/vuln.js
Normal file
27
tests/dynamic_fixtures/js_frameworks/koa/vuln.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Phase 13 (Track L.11) — Koa CMDI vuln fixture.
|
||||
//
|
||||
// The `/run` route forwards a `cmd` query parameter straight into
|
||||
// `child_process.exec`. Adapter binding: `router.get('/run', runCmd)`
|
||||
// with `cmd` flowing through `ctx.query.cmd`.
|
||||
|
||||
const Koa = require('koa');
|
||||
const Router = require('@koa/router');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
|
||||
async function runCmd(ctx) {
|
||||
const cmd = ctx.query.cmd || '';
|
||||
await new Promise((resolve) => {
|
||||
exec(cmd, (err, stdout) => {
|
||||
ctx.body = err ? String(err) : stdout;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/run', runCmd);
|
||||
app.use(router.routes());
|
||||
|
||||
module.exports = { app, runCmd };
|
||||
26
tests/dynamic_fixtures/js_frameworks/nest/benign.js
Normal file
26
tests/dynamic_fixtures/js_frameworks/nest/benign.js
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
// Phase 13 (Track L.11) — NestJS CMDI benign fixture. Same adapter
|
||||
// binding shape as the vuln fixture; the differential outcome is what
|
||||
// distinguishes the two.
|
||||
|
||||
require('reflect-metadata');
|
||||
const { Controller, Get, Query } = require('@nestjs/common');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
@Controller('')
|
||||
class AppController {
|
||||
@Get('run')
|
||||
runCmd(@Query('cmd') cmd) {
|
||||
if (!ALLOW.has(cmd || '')) {
|
||||
return 'rejected';
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
resolve(err ? String(err) : stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AppController };
|
||||
27
tests/dynamic_fixtures/js_frameworks/nest/vuln.js
Normal file
27
tests/dynamic_fixtures/js_frameworks/nest/vuln.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Phase 13 (Track L.11) — NestJS CMDI vuln fixture (Babel-stage-1
|
||||
// decorator syntax form). Real Nest projects publish their
|
||||
// controllers either as `.ts` files or as Babel-transpiled `.js`
|
||||
// carrying the inline decorator syntax via `@babel/plugin-proposal-decorators`
|
||||
// + `reflect-metadata`. The adapter binds the decorator syntax;
|
||||
// the harness loads the entry via `Test.createTestingModule`.
|
||||
//
|
||||
// Adapter binding: `@Controller('')` + `@Get('run')` on
|
||||
// `AppController.runCmd` with `cmd` flowing through `@Query('cmd')`.
|
||||
|
||||
require('reflect-metadata');
|
||||
const { Controller, Get, Query } = require('@nestjs/common');
|
||||
const { exec } = require('child_process');
|
||||
|
||||
@Controller('')
|
||||
class AppController {
|
||||
@Get('run')
|
||||
runCmd(@Query('cmd') cmd) {
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd || '', (err, stdout) => {
|
||||
resolve(err ? String(err) : stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { AppController };
|
||||
27
tests/dynamic_fixtures/ts_frameworks/express/benign.ts
Normal file
27
tests/dynamic_fixtures/ts_frameworks/express/benign.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Phase 13 (Track L.11) — Express CMDI benign fixture (TypeScript).
|
||||
|
||||
import express, { Request, Response } from 'express';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
const app = express();
|
||||
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
function runCmd(req: Request, res: Response) {
|
||||
const cmd = (req.query.cmd as string) || '';
|
||||
if (!ALLOW.has(cmd)) {
|
||||
res.status(400).send('rejected');
|
||||
return;
|
||||
}
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
if (err) {
|
||||
res.status(500).send(String(err));
|
||||
return;
|
||||
}
|
||||
res.send(stdout);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/run', runCmd);
|
||||
|
||||
export { app, runCmd };
|
||||
23
tests/dynamic_fixtures/ts_frameworks/express/vuln.ts
Normal file
23
tests/dynamic_fixtures/ts_frameworks/express/vuln.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Phase 13 (Track L.11) — Express CMDI vuln fixture (TypeScript).
|
||||
// Same shape as the JS twin; binds `app.get('/run', runCmd)` and
|
||||
// flows `req.query.cmd` straight into `exec`.
|
||||
|
||||
import express, { Request, Response } from 'express';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const app = express();
|
||||
|
||||
function runCmd(req: Request, res: Response) {
|
||||
const cmd = (req.query.cmd as string) || '';
|
||||
exec(cmd, (err, stdout) => {
|
||||
if (err) {
|
||||
res.status(500).send(String(err));
|
||||
return;
|
||||
}
|
||||
res.send(stdout);
|
||||
});
|
||||
}
|
||||
|
||||
app.get('/run', runCmd);
|
||||
|
||||
export { app, runCmd };
|
||||
25
tests/dynamic_fixtures/ts_frameworks/fastify/benign.ts
Normal file
25
tests/dynamic_fixtures/ts_frameworks/fastify/benign.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Phase 13 (Track L.11) — Fastify CMDI benign fixture (TypeScript).
|
||||
|
||||
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
const app = Fastify();
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
async function runCmd(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const cmd = ((request.query as Record<string, string>).cmd) || '';
|
||||
if (!ALLOW.has(cmd)) {
|
||||
reply.code(400).send('rejected');
|
||||
return;
|
||||
}
|
||||
const out = await new Promise<string>((resolve) => {
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
resolve(err ? String(err) : stdout);
|
||||
});
|
||||
});
|
||||
reply.send(out);
|
||||
}
|
||||
|
||||
app.get('/run', runCmd);
|
||||
|
||||
export { app, runCmd };
|
||||
18
tests/dynamic_fixtures/ts_frameworks/fastify/vuln.ts
Normal file
18
tests/dynamic_fixtures/ts_frameworks/fastify/vuln.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Phase 13 (Track L.11) — Fastify CMDI vuln fixture (TypeScript).
|
||||
|
||||
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const app = Fastify();
|
||||
|
||||
async function runCmd(request: FastifyRequest, reply: FastifyReply): Promise<void> {
|
||||
const cmd = ((request.query as Record<string, string>).cmd) || '';
|
||||
const out = await new Promise<string>((resolve) => {
|
||||
exec(cmd, (err, stdout) => resolve(err ? String(err) : stdout));
|
||||
});
|
||||
reply.send(out);
|
||||
}
|
||||
|
||||
app.get('/run', runCmd);
|
||||
|
||||
export { app, runCmd };
|
||||
29
tests/dynamic_fixtures/ts_frameworks/koa/benign.ts
Normal file
29
tests/dynamic_fixtures/ts_frameworks/koa/benign.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
// Phase 13 (Track L.11) — Koa CMDI benign fixture (TypeScript).
|
||||
|
||||
import Koa from 'koa';
|
||||
import Router from '@koa/router';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
async function runCmd(ctx: Koa.Context): Promise<void> {
|
||||
const cmd = (ctx.query.cmd as string) || '';
|
||||
if (!ALLOW.has(cmd)) {
|
||||
ctx.status = 400;
|
||||
ctx.body = 'rejected';
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => {
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
ctx.body = err ? String(err) : stdout;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/run', runCmd);
|
||||
app.use(router.routes());
|
||||
|
||||
export { app, runCmd };
|
||||
23
tests/dynamic_fixtures/ts_frameworks/koa/vuln.ts
Normal file
23
tests/dynamic_fixtures/ts_frameworks/koa/vuln.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// Phase 13 (Track L.11) — Koa CMDI vuln fixture (TypeScript).
|
||||
|
||||
import Koa from 'koa';
|
||||
import Router from '@koa/router';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
const app = new Koa();
|
||||
const router = new Router();
|
||||
|
||||
async function runCmd(ctx: Koa.Context): Promise<void> {
|
||||
const cmd = (ctx.query.cmd as string) || '';
|
||||
await new Promise<void>((resolve) => {
|
||||
exec(cmd, (err, stdout) => {
|
||||
ctx.body = err ? String(err) : stdout;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/run', runCmd);
|
||||
app.use(router.routes());
|
||||
|
||||
export { app, runCmd };
|
||||
22
tests/dynamic_fixtures/ts_frameworks/nest/benign.ts
Normal file
22
tests/dynamic_fixtures/ts_frameworks/nest/benign.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Phase 13 (Track L.11) — NestJS CMDI benign fixture (TypeScript).
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { execFile } from 'child_process';
|
||||
|
||||
const ALLOW = new Set(['status', 'uptime', 'version']);
|
||||
|
||||
@Controller('')
|
||||
export class AppController {
|
||||
@Get('run')
|
||||
runCmd(@Query('cmd') cmd: string): Promise<string> | string {
|
||||
if (!ALLOW.has(cmd || '')) {
|
||||
return 'rejected';
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
execFile('/usr/bin/echo', [cmd], (err, stdout) => {
|
||||
resolve(err ? String(err) : stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts
Normal file
20
tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
// Phase 13 (Track L.11) — NestJS CMDI vuln fixture (TypeScript).
|
||||
//
|
||||
// Adapter binding: `@Controller('')` + `@Get('run')` on
|
||||
// `AppController.runCmd` with `cmd` flowing through `@Query('cmd')`.
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import { exec } from 'child_process';
|
||||
|
||||
@Controller('')
|
||||
export class AppController {
|
||||
@Get('run')
|
||||
runCmd(@Query('cmd') cmd: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
exec(cmd || '', (err, stdout) => {
|
||||
resolve(err ? String(err) : stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
182
tests/js_frameworks_corpus.rs
Normal file
182
tests/js_frameworks_corpus.rs
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//! Phase 13 (Track L.11) — JS framework adapter integration tests.
|
||||
//!
|
||||
//! Each test exercises `detect_binding` end-to-end against a fixture
|
||||
//! file under `tests/dynamic_fixtures/js_frameworks/`, asserting that
|
||||
//! the right adapter fires, the binding carries
|
||||
//! `EntryKind::HttpRoute`, and the `RouteShape` + per-formal
|
||||
//! `request_params` match the brief's contract. Benign fixtures must
|
||||
//! produce the same adapter binding shape as the vuln fixtures — the
|
||||
//! adapter only models the route, the differential outcome of a
|
||||
//! verifier run is what distinguishes the two.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource};
|
||||
use nyx_scanner::evidence::EntryKind;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
fn parse_js(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_javascript::LANGUAGE);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, file: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: file.into(),
|
||||
lang: "javascript".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn express_vuln_fixture_binds_route() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/express/vuln.js";
|
||||
let bytes = std::fs::read(path).expect("express vuln fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("express adapter must bind");
|
||||
assert_eq!(binding.adapter, "js-express");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert!(binding
|
||||
.request_params
|
||||
.iter()
|
||||
.any(|p| p.name == "req" && matches!(p.source, ParamSource::Implicit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn express_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/express/benign.js";
|
||||
let bytes = std::fs::read(path).expect("express benign fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("express adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "js-express");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn koa_vuln_fixture_binds_router_route() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/koa/vuln.js";
|
||||
let bytes = std::fs::read(path).expect("koa vuln fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("koa adapter must bind");
|
||||
assert_eq!(binding.adapter, "js-koa");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert!(binding
|
||||
.request_params
|
||||
.iter()
|
||||
.any(|p| p.name == "ctx" && matches!(p.source, ParamSource::Implicit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn koa_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/koa/benign.js";
|
||||
let bytes = std::fs::read(path).expect("koa benign fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("koa adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "js-koa");
|
||||
assert_eq!(binding.route.as_ref().unwrap().path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastify_vuln_fixture_binds_route() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/fastify/vuln.js";
|
||||
let bytes = std::fs::read(path).expect("fastify vuln fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("fastify adapter must bind");
|
||||
assert_eq!(binding.adapter, "js-fastify");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert!(binding
|
||||
.request_params
|
||||
.iter()
|
||||
.any(|p| p.name == "request" && matches!(p.source, ParamSource::Implicit)));
|
||||
assert!(binding
|
||||
.request_params
|
||||
.iter()
|
||||
.any(|p| p.name == "reply" && matches!(p.source, ParamSource::Implicit)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fastify_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/fastify/benign.js";
|
||||
let bytes = std::fs::read(path).expect("fastify benign fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("fastify adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "js-fastify");
|
||||
assert_eq!(binding.route.as_ref().unwrap().path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_vuln_fixture_binds_controller_route() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/nest/vuln.js";
|
||||
let bytes = std::fs::read(path).expect("nest vuln fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("nest adapter must bind");
|
||||
assert_eq!(binding.adapter, "js-nest");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
let cmd_binding = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "cmd")
|
||||
.expect("cmd formal");
|
||||
match &cmd_binding.source {
|
||||
ParamSource::QueryParam(q) => assert_eq!(q, "cmd"),
|
||||
other => panic!("expected QueryParam(\"cmd\"), got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/js_frameworks/nest/benign.js";
|
||||
let bytes = std::fs::read(path).expect("nest benign fixture exists");
|
||||
let tree = parse_js(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::JavaScript)
|
||||
.expect("nest adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "js-nest");
|
||||
assert_eq!(binding.route.as_ref().unwrap().path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn express_adapter_runs_before_fastify_for_express_files() {
|
||||
// Regression guard: an Express file does not pull in `fastify`,
|
||||
// so the Fastify adapter never fires. Registration order is
|
||||
// alphabetical (`js-express` before `js-fastify`) which keeps the
|
||||
// adapter dispatch deterministic.
|
||||
let src: &[u8] = b"const express = require('express');\n\
|
||||
const app = express();\n\
|
||||
function h(req, res) { res.send('ok'); }\n\
|
||||
app.get('/x', h);\n";
|
||||
let tree = parse_js(src);
|
||||
let summary = summary_for("h", "synthetic.js");
|
||||
let binding =
|
||||
detect_binding(&summary, tree.root_node(), src, Lang::JavaScript).expect("fires");
|
||||
assert_eq!(binding.adapter, "js-express");
|
||||
}
|
||||
68
tests/ts_frameworks_corpus.rs
Normal file
68
tests/ts_frameworks_corpus.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
//! Phase 13 (Track L.11) — TypeScript framework adapter integration tests.
|
||||
//!
|
||||
//! Mirrors `tests/js_frameworks_corpus.rs` against the TS fixtures.
|
||||
//! The Express / Koa / Fastify adapters are registered under
|
||||
//! [`Lang::JavaScript`] only (TypeScript code paths share the JS
|
||||
//! adapter via the Lang dispatch); the Nest adapter is registered
|
||||
//! under both [`Lang::JavaScript`] and [`Lang::TypeScript`] because
|
||||
//! Nest is TypeScript-first.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource};
|
||||
use nyx_scanner::evidence::EntryKind;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
fn parse_ts(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang =
|
||||
tree_sitter::Language::from(tree_sitter_typescript::LANGUAGE_TYPESCRIPT);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary_for(name: &str, file: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: file.into(),
|
||||
lang: "typescript".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_ts_vuln_fixture_binds_controller_route() {
|
||||
let path = "tests/dynamic_fixtures/ts_frameworks/nest/vuln.ts";
|
||||
let bytes = std::fs::read(path).expect("nest TS vuln fixture exists");
|
||||
let tree = parse_ts(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::TypeScript)
|
||||
.expect("ts-nest adapter must bind");
|
||||
assert_eq!(binding.adapter, "ts-nest");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
let cmd_binding = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "cmd")
|
||||
.expect("cmd formal");
|
||||
match &cmd_binding.source {
|
||||
ParamSource::QueryParam(q) => assert_eq!(q, "cmd"),
|
||||
other => panic!("expected QueryParam, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nest_ts_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/ts_frameworks/nest/benign.ts";
|
||||
let bytes = std::fs::read(path).expect("nest TS benign fixture exists");
|
||||
let tree = parse_ts(&bytes);
|
||||
let summary = summary_for("runCmd", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::TypeScript)
|
||||
.expect("ts-nest adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "ts-nest");
|
||||
assert_eq!(binding.route.as_ref().unwrap().path, "/run");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue