[pitboss] phase 17: Track L.15 — Gin / Echo / Fiber / Chi adapters + Axum / Actix / Rocket / Warp adapters

This commit is contained in:
pitboss 2026-05-20 12:24:31 -05:00
parent 5393fe22f2
commit 2b96c6005b
33 changed files with 3247 additions and 27 deletions

View file

@ -0,0 +1,126 @@
//! Chi [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises the canonical chi route declaration:
//!
//! ```go
//! r := chi.NewRouter()
//! r.Get("/users/{id}", Show)
//! r.Post("/save", func(w http.ResponseWriter, r *http.Request) {})
//! ```
//!
//! Chi uses brace placeholders (`{id}`, `{id:[0-9]+}`) and pascal-
//! cased verb methods. Handler signature is `func(w, r)` — the
//! request-param binder treats `w` / `r` as implicit context.
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::go_routes::{
bind_go_path_params, find_go_function, find_route_for_callee, go_formal_names,
source_imports_chi,
};
pub struct GoChiAdapter;
const ADAPTER_NAME: &str = "go-chi";
impl FrameworkAdapter for GoChiAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_chi(file_bytes) {
return None;
}
let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?;
let request_params = find_go_function(ast, file_bytes, &summary.name)
.map(|func| {
let formals = go_formal_names(func, file_bytes);
bind_go_path_params(&formals, &path)
})
.unwrap_or_default();
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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "go".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_with_brace_placeholder() {
let src: &[u8] = b"package main\nimport (\"net/http\"; \"github.com/go-chi/chi/v5\")\n\
func init() { r := chi.NewRouter(); r.Get(\"/users/{id}\", Show) }\n\
func Show(w http.ResponseWriter, r *http.Request) {}\n";
let tree = parse(src);
let binding = GoChiAdapter
.detect(&summary("Show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "go-chi");
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/users/{id}");
}
#[test]
fn fires_on_regex_placeholder() {
let src: &[u8] = b"package main\nimport \"github.com/go-chi/chi/v5\"\n\
func init() { r := chi.NewRouter(); r.Get(\"/u/{id:[0-9]+}\", Show) }\n\
func Show(w interface{}, id string) {}\n";
let tree = parse(src);
let binding = GoChiAdapter
.detect(&summary("Show"), tree.root_node(), src)
.expect("binding");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn skips_when_chi_not_imported() {
let src: &[u8] = b"package main\nfunc Show() {}\n";
let tree = parse(src);
assert!(GoChiAdapter
.detect(&summary("Show"), tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,127 @@
//! Echo [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises the canonical echo route declaration:
//!
//! ```go
//! e := echo.New()
//! e.GET("/users/:id", Show)
//! e.POST("/save", func(c echo.Context) error { return nil })
//! ```
//!
//! The adapter binds the route to the function whose name matches
//! `summary.name`; the path-placeholder syntax (`:id`) shares the
//! same vocabulary as gin / fiber.
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::go_routes::{
bind_go_path_params, find_go_function, find_route_for_callee, go_formal_names,
source_imports_echo,
};
pub struct GoEchoAdapter;
const ADAPTER_NAME: &str = "go-echo";
impl FrameworkAdapter for GoEchoAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_echo(file_bytes) {
return None;
}
let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?;
let request_params = find_go_function(ast, file_bytes, &summary.name)
.map(|func| {
let formals = go_formal_names(func, file_bytes);
bind_go_path_params(&formals, &path)
})
.unwrap_or_default();
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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "go".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_with_identifier_callable() {
let src: &[u8] = b"package main\nimport \"github.com/labstack/echo/v4\"\n\
func init() { e := echo.New(); e.GET(\"/users/:id\", Show) }\n\
func Show(c echo.Context, id string) error { return nil }\n";
let tree = parse(src);
let binding = GoEchoAdapter
.detect(&summary("Show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "go-echo");
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/users/:id");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn fires_on_put_verb() {
let src: &[u8] = b"package main\nimport \"github.com/labstack/echo\"\n\
func init() { e := echo.New(); e.PUT(\"/users/:id\", Update) }\n\
func Update(c echo.Context, id string) error { return nil }\n";
let tree = parse(src);
let binding = GoEchoAdapter
.detect(&summary("Update"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.route.unwrap().method, HttpMethod::PUT);
}
#[test]
fn skips_when_echo_not_imported() {
let src: &[u8] = b"package main\nfunc Show() {}\n";
let tree = parse(src);
assert!(GoEchoAdapter
.detect(&summary("Show"), tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,133 @@
//! Fiber [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises the canonical fiber route declaration:
//!
//! ```go
//! app := fiber.New()
//! app.Get("/users/:id", Show)
//! app.Post("/save", func(c *fiber.Ctx) error { return nil })
//! ```
//!
//! Fiber uses pascal-cased verb methods (`Get`/`Post`/`Put`/...), and
//! its path vocabulary includes `:id`, `:id?` (optional), `+name`
//! (greedy non-empty), and `*name` (greedy match-all). All three
//! placeholder shapes resolve via [`super::go_routes::extract_go_path_placeholders`].
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::go_routes::{
bind_go_path_params, find_go_function, find_route_for_callee, go_formal_names,
source_imports_fiber,
};
pub struct GoFiberAdapter;
const ADAPTER_NAME: &str = "go-fiber";
impl FrameworkAdapter for GoFiberAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_fiber(file_bytes) {
return None;
}
let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?;
let request_params = find_go_function(ast, file_bytes, &summary.name)
.map(|func| {
let formals = go_formal_names(func, file_bytes);
bind_go_path_params(&formals, &path)
})
.unwrap_or_default();
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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "go".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_with_identifier_callable() {
let src: &[u8] = b"package main\nimport \"github.com/gofiber/fiber/v2\"\n\
func init() { app := fiber.New(); app.Get(\"/users/:id\", Show) }\n\
func Show(c *fiber.Ctx, id string) error { return nil }\n";
let tree = parse(src);
let binding = GoFiberAdapter
.detect(&summary("Show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "go-fiber");
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/users/:id");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn fires_on_greedy_plus_wildcard() {
let src: &[u8] = b"package main\nimport \"github.com/gofiber/fiber/v2\"\n\
func init() { app := fiber.New(); app.Get(\"/files/+rest\", Stream) }\n\
func Stream(c *fiber.Ctx, rest string) error { return nil }\n";
let tree = parse(src);
let binding = GoFiberAdapter
.detect(&summary("Stream"), tree.root_node(), src)
.expect("binding");
let rest = binding
.request_params
.iter()
.find(|p| p.name == "rest")
.unwrap();
assert!(matches!(rest.source, ParamSource::PathSegment(_)));
}
#[test]
fn skips_when_fiber_not_imported() {
let src: &[u8] = b"package main\nfunc Show() {}\n";
let tree = parse(src);
assert!(GoFiberAdapter
.detect(&summary("Show"), tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,152 @@
//! Gin [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises the canonical gin route declaration:
//!
//! ```go
//! r := gin.Default()
//! r.GET("/users/:id", Show)
//! r.POST("/save", func(c *gin.Context) { /* ... */ })
//! ```
//!
//! The adapter binds the route to the function whose name matches
//! `summary.name` either via a bare identifier callable, a selector
//! callable (`controllers.Show`), or via a func literal (closure)
//! that this implementation accepts as a wildcard because the
//! surrounding adapter has already narrowed to the func whose name
//! matches the summary.
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::go_routes::{
bind_go_path_params, find_go_function, find_route_for_callee, go_formal_names,
source_imports_gin,
};
pub struct GoGinAdapter;
const ADAPTER_NAME: &str = "go-gin";
impl FrameworkAdapter for GoGinAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Go
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_gin(file_bytes) {
return None;
}
let (method, path) = find_route_for_callee(ast, file_bytes, &summary.name)?;
let request_params = find_go_function(ast, file_bytes, &summary.name)
.map(|func| {
let formals = go_formal_names(func, file_bytes);
bind_go_path_params(&formals, &path)
})
.unwrap_or_default();
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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "go".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_with_identifier_callable() {
let src: &[u8] = b"package main\nimport \"github.com/gin-gonic/gin\"\n\
func init() { r := gin.Default(); r.GET(\"/users/:id\", Show) }\n\
func Show(c *gin.Context, id string) {}\n";
let tree = parse(src);
let binding = GoGinAdapter
.detect(&summary("Show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "go-gin");
assert_eq!(binding.kind, EntryKind::HttpRoute);
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/users/:id");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn fires_on_post_with_closure() {
let src: &[u8] = b"package main\nimport \"github.com/gin-gonic/gin\"\n\
func Save(c *gin.Context) {}\n\
func init() { r := gin.Default(); r.POST(\"/save\", Save) }\n";
let tree = parse(src);
let binding = GoGinAdapter
.detect(&summary("Save"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.route.unwrap().method, HttpMethod::POST);
}
#[test]
fn skips_when_gin_not_imported() {
let src: &[u8] = b"package main\nfunc Show(id string) {}\n";
let tree = parse(src);
assert!(GoGinAdapter
.detect(&summary("Show"), tree.root_node(), src)
.is_none());
}
#[test]
fn skips_when_route_does_not_reference_function() {
let src: &[u8] =
b"package main\nimport \"github.com/gin-gonic/gin\"\nfunc init() { r := gin.Default(); r.GET(\"/users\", Show) }\nfunc Helper(x string) {}\n";
let tree = parse(src);
assert!(GoGinAdapter
.detect(&summary("Helper"), tree.root_node(), src)
.is_none());
}
#[test]
fn fires_on_marker_comment() {
let src: &[u8] =
b"// nyx-shape: gin\npackage main\nfunc init() { r.GET(\"/x\", Show) }\nfunc Show(c interface{}) {}\n";
let tree = parse(src);
let binding = GoGinAdapter
.detect(&summary("Show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "go-gin");
}
}

View file

@ -0,0 +1,456 @@
//! Shared Go-route adapter helpers (Phase 17 — Track L.15).
//!
//! The gin / echo / fiber / chi adapters all need the same handful
//! of tree-sitter helpers: locate a `func` declaration by name,
//! enumerate formal parameter names, walk the file looking for a
//! `engine.GET("/path", handler)` / `router.Post("/x", handler)` call
//! whose callable references a target function name, parse a path
//! template into placeholder names, and bind formals to request
//! slots. Centralising the helpers here keeps the four adapters
//! terse and lets every framework share the same placeholder-binding
//! semantics.
//!
//! Path placeholder vocabulary:
//! - gin / echo / chi use `:id` and (chi) `{id}` interchangeably.
//! - fiber uses `:id` and `+` / `*` greedy wildcards.
//! [`extract_go_path_placeholders`] supports both syntaxes.
use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource};
use tree_sitter::Node;
/// True when `bytes` carries any of the well-known gin markers.
pub fn source_imports_gin(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"github.com/gin-gonic/gin",
b"gin.Engine",
b"gin.Default",
b"gin.New",
b"// nyx-shape: gin",
],
)
}
/// True when `bytes` carries any of the well-known echo markers.
pub fn source_imports_echo(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"github.com/labstack/echo",
b"echo.Echo",
b"echo.New",
b"echo.Context",
b"// nyx-shape: echo",
],
)
}
/// True when `bytes` carries any of the well-known fiber markers.
pub fn source_imports_fiber(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"github.com/gofiber/fiber",
b"fiber.App",
b"fiber.New",
b"fiber.Ctx",
b"// nyx-shape: fiber",
],
)
}
/// True when `bytes` carries any of the well-known chi markers.
pub fn source_imports_chi(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"github.com/go-chi/chi",
b"chi.NewRouter",
b"chi.Mux",
b"chi.Router",
b"// nyx-shape: chi",
],
)
}
fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
needles
.iter()
.any(|n| haystack.windows(n.len()).any(|w| w == *n))
}
/// Find a top-level `function_declaration` or a `method_declaration`
/// whose name equals `target`. Returns the matching node.
pub fn find_go_function<'a>(
root: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<Node<'a>> {
let mut hit: Option<Node<'a>> = None;
walk_go(root, bytes, target, &mut hit);
hit
}
fn walk_go<'a>(
node: Node<'a>,
bytes: &'a [u8],
target: &str,
out: &mut Option<Node<'a>>,
) {
if out.is_some() {
return;
}
match node.kind() {
"function_declaration" | "method_declaration" => {
if let Some(name) = node.child_by_field_name("name")
&& let Ok(text) = name.utf8_text(bytes)
&& text == target
{
*out = Some(node);
return;
}
}
_ => {}
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_go(child, bytes, target, out);
}
}
/// Read formal parameter names from a `function_declaration` /
/// `method_declaration` / `func_literal`. Drops the receiver
/// parameter of a method (it is not part of the request surface).
pub fn go_formal_names(func: Node<'_>, bytes: &[u8]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let Some(params) = func.child_by_field_name("parameters") else {
return out;
};
let mut cur = params.walk();
for p in params.named_children(&mut cur) {
if p.kind() != "parameter_declaration" {
continue;
}
let mut pc = p.walk();
for c in p.named_children(&mut pc) {
if c.kind() == "identifier" {
if let Ok(text) = c.utf8_text(bytes) {
out.push(text.to_owned());
}
}
}
}
out
}
/// Extract placeholder names from a Go route path template.
///
/// Supports:
/// - gin / echo / fiber `:id` style: `/u/:id` → `id`
/// - chi `{id}` style: `/u/{id}` → `id`
/// - fiber `+` greedy: `/files/+rest` → `rest`
/// - fiber/chi `*` wildcard: `/files/*rest` → `rest`
pub fn extract_go_path_placeholders(path: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |name: String| {
if !name.is_empty() && !out.iter().any(|n| n == &name) {
out.push(name);
}
};
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());
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;
}
}
b'*' | 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());
i = j;
continue;
}
}
_ => {}
}
i += 1;
}
out
}
/// Bind formals to request slots given a Go route path template.
///
/// `c` / `ctx` / `w` / `r` formals become [`ParamSource::Implicit`]
/// (the framework context object or `http.ResponseWriter` /
/// `*http.Request` pair). Names matching the path placeholder list
/// become [`ParamSource::PathSegment`]. Every other formal falls
/// back to a [`ParamSource::QueryParam`] of the same name.
pub fn bind_go_path_params(formals: &[String], path: &str) -> Vec<ParamBinding> {
let placeholders = extract_go_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, "c" | "ctx" | "w" | "r" | "req" | "res" | "rw")
}
/// Parse Go verb-method names: `GET`, `POST`, `PUT`, `PATCH`,
/// `DELETE`, `HEAD`, `OPTIONS` (case-insensitive — gin uses upper,
/// echo / chi use upper, fiber uses pascal-cased like `Get`,
/// `Post`). Returns `None` for unrelated identifiers.
pub fn verb_from_method(method: &str) -> Option<HttpMethod> {
let upper = method.to_ascii_uppercase();
match upper.as_str() {
"GET" => Some(HttpMethod::GET),
"POST" => Some(HttpMethod::POST),
"PUT" => Some(HttpMethod::PUT),
"PATCH" => Some(HttpMethod::PATCH),
"DELETE" => Some(HttpMethod::DELETE),
"HEAD" => Some(HttpMethod::HEAD),
"OPTIONS" => Some(HttpMethod::OPTIONS),
_ => None,
}
}
/// Locate the `(method, path)` of a `receiver.Verb("/path", target)`
/// call expression registered against `target` in the file. Walks
/// every `call_expression` in `root` and inspects each one whose
/// callee is a `selector_expression` of the shape
/// `<receiver>.<Verb>(<string>, <callable>)`. Returns `None` when no
/// such call references `target` directly.
///
/// `target` matches against:
/// - bare identifier callee (`r.GET("/x", handler)`)
/// - qualified callee whose last segment equals `target`
/// (`r.GET("/x", controllers.Show)`)
/// - method-value callee (`r.GET("/x", (&UserController{}).Show)`)
pub fn find_route_for_callee<'a>(
root: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<(HttpMethod, String)> {
let mut hit: Option<(HttpMethod, String)> = None;
walk_routes(root, bytes, target, &mut hit);
hit
}
fn walk_routes<'a>(
node: Node<'a>,
bytes: &'a [u8],
target: &str,
out: &mut Option<(HttpMethod, String)>,
) {
if out.is_some() {
return;
}
if node.kind() == "call_expression"
&& let Some(found) = try_route_call(node, bytes, target)
{
*out = Some(found);
return;
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_routes(child, bytes, target, out);
}
}
fn try_route_call<'a>(
call: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<(HttpMethod, String)> {
let callee = call.child_by_field_name("function")?;
if callee.kind() != "selector_expression" {
return None;
}
let verb_node = callee.child_by_field_name("field")?.utf8_text(bytes).ok()?;
let method = verb_from_method(verb_node)?;
let args = call.child_by_field_name("arguments")?;
let positional: Vec<Node<'_>> = {
let mut cur = args.walk();
args.named_children(&mut cur)
.filter(|c| c.kind() != "comment")
.collect()
};
if positional.len() < 2 {
return None;
}
let path = go_string_literal(positional[0], bytes)?;
if !callable_matches(positional[1], bytes, target) {
return None;
}
Some((method, path))
}
/// Read a Go interpreted_string_literal's content, dropping the
/// surrounding `"` quotes. Returns `None` if `node` is not a string
/// literal.
pub fn go_string_literal(node: Node<'_>, bytes: &[u8]) -> Option<String> {
if node.kind() != "interpreted_string_literal" && node.kind() != "raw_string_literal" {
return None;
}
let raw = node.utf8_text(bytes).ok()?;
let trimmed = raw.trim();
if trimmed.len() < 2 {
return None;
}
let first = trimmed.as_bytes()[0];
let last = trimmed.as_bytes()[trimmed.len() - 1];
if (first == b'"' && last == b'"') || (first == b'`' && last == b'`') {
Some(trimmed[1..trimmed.len() - 1].to_owned())
} else {
None
}
}
/// True when the callable argument resolves to `target`. Accepts:
/// - bare identifier (`Handler`)
/// - selector chain (`controllers.Show`, `c.Show`)
/// - func literal — wildcard (the surrounding adapter already
/// narrowed to a Go function whose name matches the summary)
/// - method-value calls — wildcard
fn callable_matches(node: Node<'_>, bytes: &[u8], target: &str) -> bool {
match node.kind() {
"identifier" => node.utf8_text(bytes).map(|s| s == target).unwrap_or(false),
"selector_expression" => {
let Some(field) = node.child_by_field_name("field") else {
return false;
};
field.utf8_text(bytes).map(|s| s == target).unwrap_or(false)
}
"func_literal" => true,
"call_expression" => true,
_ => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn extracts_colon_placeholders() {
assert_eq!(extract_go_path_placeholders("/u/:id"), vec!["id"]);
assert_eq!(
extract_go_path_placeholders("/u/:id/posts/:slug"),
vec!["id", "slug"]
);
}
#[test]
fn extracts_brace_placeholders() {
assert_eq!(extract_go_path_placeholders("/u/{id}"), vec!["id"]);
assert_eq!(extract_go_path_placeholders("/u/{id:[0-9]+}"), vec!["id"]);
}
#[test]
fn extracts_fiber_wildcards() {
assert_eq!(extract_go_path_placeholders("/files/+rest"), vec!["rest"]);
assert_eq!(extract_go_path_placeholders("/files/*rest"), vec!["rest"]);
}
#[test]
fn binds_known_placeholder_as_path_segment() {
let formals = vec!["c".to_string(), "id".to_string(), "extra".to_string()];
let bindings = bind_go_path_params(&formals, "/u/:id");
assert!(matches!(bindings[0].source, ParamSource::Implicit));
assert!(matches!(bindings[1].source, ParamSource::PathSegment(_)));
assert!(matches!(bindings[2].source, ParamSource::QueryParam(_)));
}
#[test]
fn verb_recognises_pascal_case() {
assert_eq!(verb_from_method("GET"), Some(HttpMethod::GET));
assert_eq!(verb_from_method("Get"), Some(HttpMethod::GET));
assert_eq!(verb_from_method("post"), Some(HttpMethod::POST));
assert_eq!(verb_from_method("Handler"), None);
}
#[test]
fn finds_function_declaration() {
let src: &[u8] = b"package main\nfunc Show(c interface{}) {}\n";
let tree = parse(src);
let n = find_go_function(tree.root_node(), src, "Show").unwrap();
assert_eq!(n.kind(), "function_declaration");
}
#[test]
fn finds_route_for_bare_identifier_callee() {
let src: &[u8] =
b"package main\nfunc init() { r := gin.New(); r.GET(\"/u/:id\", Show) }\nfunc Show(c interface{}) {}\n";
let tree = parse(src);
let (method, path) =
find_route_for_callee(tree.root_node(), src, "Show").expect("hit");
assert_eq!(method, HttpMethod::GET);
assert_eq!(path, "/u/:id");
}
#[test]
fn finds_route_for_selector_callee() {
let src: &[u8] =
b"package main\nfunc init() { r := chi.NewRouter(); r.Get(\"/x\", controllers.Show) }\n";
let tree = parse(src);
let (method, path) =
find_route_for_callee(tree.root_node(), src, "Show").expect("hit");
assert_eq!(method, HttpMethod::GET);
assert_eq!(path, "/x");
}
#[test]
fn formal_names_skip_types() {
let src: &[u8] = b"package main\nfunc Show(c *gin.Context, id string) {}\n";
let tree = parse(src);
let f = find_go_function(tree.root_node(), src, "Show").unwrap();
let names = go_formal_names(f, src);
assert_eq!(names, vec!["c", "id"]);
}
}

View file

@ -18,6 +18,11 @@ pub mod header_php;
pub mod header_python;
pub mod header_ruby;
pub mod header_rust;
pub mod go_chi;
pub mod go_echo;
pub mod go_fiber;
pub mod go_gin;
pub mod go_routes;
pub mod java_deserialize;
pub mod java_micronaut;
pub mod java_quarkus;
@ -63,6 +68,11 @@ pub mod ruby_marshal;
pub mod ruby_rails;
pub mod ruby_routes;
pub mod ruby_sinatra;
pub mod rust_actix;
pub mod rust_axum;
pub mod rust_rocket;
pub mod rust_routes;
pub mod rust_warp;
pub mod xpath_java;
pub mod xpath_js;
pub mod xpath_php;
@ -80,6 +90,10 @@ pub use header_php::HeaderPhpAdapter;
pub use header_python::HeaderPythonAdapter;
pub use header_ruby::HeaderRubyAdapter;
pub use header_rust::HeaderRustAdapter;
pub use go_chi::GoChiAdapter;
pub use go_echo::GoEchoAdapter;
pub use go_fiber::GoFiberAdapter;
pub use go_gin::GoGinAdapter;
pub use java_deserialize::JavaDeserializeAdapter;
pub use java_micronaut::JavaMicronautAdapter;
pub use java_quarkus::JavaQuarkusAdapter;
@ -120,6 +134,10 @@ pub use ruby_hanami::RubyHanamiAdapter;
pub use ruby_marshal::RubyMarshalAdapter;
pub use ruby_rails::RubyRailsAdapter;
pub use ruby_sinatra::RubySinatraAdapter;
pub use rust_actix::RustActixAdapter;
pub use rust_axum::RustAxumAdapter;
pub use rust_rocket::RustRocketAdapter;
pub use rust_warp::RustWarpAdapter;
pub use xpath_java::XpathJavaAdapter;
pub use xpath_js::XpathJsAdapter;
pub use xpath_php::XpathPhpAdapter;

View file

@ -0,0 +1,129 @@
//! Actix-web [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises actix's `#[get("/path")]` / `#[post("/path")]`
//! attribute macros on handler functions:
//!
//! ```rust
//! #[get("/users/{id}")]
//! async fn show(id: web::Path<String>) -> impl Responder { id }
//! ```
//!
//! The adapter walks the attribute_items immediately preceding the
//! `function_item` named `summary.name`, picks up the verb leaf
//! (`get` / `post` / ...) and the first string-literal argument.
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::rust_routes::{
bind_rust_path_params, find_method_attribute, find_rust_function, rust_formal_names,
source_imports_actix,
};
pub struct RustActixAdapter;
const ADAPTER_NAME: &str = "rust-actix";
impl FrameworkAdapter for RustActixAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_actix(file_bytes) {
return None;
}
let func = find_rust_function(ast, file_bytes, &summary.name)?;
let (method, path) = find_method_attribute(func, file_bytes)?;
let formals = rust_formal_names(func, file_bytes);
let request_params = bind_rust_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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "rust".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_attribute() {
let src: &[u8] = b"use actix_web::get;\n#[get(\"/u/{id}\")]\nasync fn show(id: String) -> String { id }\n";
let tree = parse(src);
let binding = RustActixAdapter
.detect(&summary("show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "rust-actix");
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/u/{id}");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn fires_on_post_attribute() {
let src: &[u8] = b"use actix_web::post;\n#[post(\"/save\")]\nasync fn save(body: String) -> String { body }\n";
let tree = parse(src);
let binding = RustActixAdapter
.detect(&summary("save"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.route.unwrap().method, HttpMethod::POST);
}
#[test]
fn skips_when_actix_not_imported() {
let src: &[u8] = b"#[get(\"/u\")]\nfn show() {}\n";
let tree = parse(src);
assert!(RustActixAdapter
.detect(&summary("show"), tree.root_node(), src)
.is_none());
}
#[test]
fn skips_when_attribute_missing() {
let src: &[u8] = b"use actix_web::App;\nfn helper(x: String) {}\n";
let tree = parse(src);
assert!(RustActixAdapter
.detect(&summary("helper"), tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,132 @@
//! Axum [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises the canonical axum route builder:
//!
//! ```rust
//! let app = Router::new()
//! .route("/users/{id}", get(show))
//! .route("/save", post(save));
//! ```
//!
//! The adapter binds the route to the function whose name matches
//! `summary.name`. Both the lowercase `get(handler)` helper and the
//! scoped `axum::routing::get(handler)` form are accepted.
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::rust_routes::{
bind_rust_path_params, find_axum_route, find_rust_function, rust_formal_names,
source_imports_axum,
};
pub struct RustAxumAdapter;
const ADAPTER_NAME: &str = "rust-axum";
impl FrameworkAdapter for RustAxumAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_axum(file_bytes) {
return None;
}
let (method, path) = find_axum_route(ast, file_bytes, &summary.name)?;
let request_params = find_rust_function(ast, file_bytes, &summary.name)
.map(|func| {
let formals = rust_formal_names(func, file_bytes);
bind_rust_path_params(&formals, &path)
})
.unwrap_or_default();
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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "rust".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_handler() {
let src: &[u8] = b"use axum::Router;\nfn build() -> Router { Router::new().route(\"/u/{id}\", get(show)) }\nfn show(id: String) -> String { id }\n";
let tree = parse(src);
let binding = RustAxumAdapter
.detect(&summary("show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "rust-axum");
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/u/{id}");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn fires_on_scoped_post_handler() {
let src: &[u8] = b"use axum::Router;\nfn build() -> Router { Router::new().route(\"/save\", axum::routing::post(save)) }\nfn save(body: String) {}\n";
let tree = parse(src);
let binding = RustAxumAdapter
.detect(&summary("save"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.route.unwrap().method, HttpMethod::POST);
}
#[test]
fn skips_when_axum_not_imported() {
let src: &[u8] = b"fn show() {}\n";
let tree = parse(src);
assert!(RustAxumAdapter
.detect(&summary("show"), tree.root_node(), src)
.is_none());
}
#[test]
fn skips_when_route_does_not_reference_function() {
let src: &[u8] = b"use axum::Router;\nfn build() -> Router { Router::new().route(\"/u\", get(show)) }\nfn helper() {}\n";
let tree = parse(src);
assert!(RustAxumAdapter
.detect(&summary("helper"), tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,125 @@
//! Rocket [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises rocket's `#[get("/path")]` / `#[post("/path")]`
//! attribute macros plus the `routes![handler]` macro:
//!
//! ```rust
//! #[get("/users/<id>")]
//! fn show(id: String) -> String { id }
//!
//! #[launch]
//! fn rocket() -> _ { rocket::build().mount("/", routes![show]) }
//! ```
//!
//! Rocket's placeholder syntax `<id>` plus brace syntax `<id..>`
//! resolve via [`super::rust_routes::extract_rust_path_placeholders`].
//! The adapter shares the attribute-walk path with actix; the only
//! difference is the source-import discriminator.
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::rust_routes::{
bind_rust_path_params, find_method_attribute, find_rust_function, rust_formal_names,
source_imports_rocket,
};
pub struct RustRocketAdapter;
const ADAPTER_NAME: &str = "rust-rocket";
impl FrameworkAdapter for RustRocketAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_rocket(file_bytes) {
return None;
}
let func = find_rust_function(ast, file_bytes, &summary.name)?;
let (method, path) = find_method_attribute(func, file_bytes)?;
let formals = rust_formal_names(func, file_bytes);
let request_params = bind_rust_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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "rust".into(),
..Default::default()
}
}
#[test]
fn fires_on_get_with_angle_placeholder() {
let src: &[u8] = b"use rocket::get;\n#[get(\"/u/<id>\")]\nfn show(id: String) -> String { id }\n";
let tree = parse(src);
let binding = RustRocketAdapter
.detect(&summary("show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "rust-rocket");
let route = binding.route.expect("route");
assert_eq!(route.method, HttpMethod::GET);
assert_eq!(route.path, "/u/<id>");
let id = binding
.request_params
.iter()
.find(|p| p.name == "id")
.unwrap();
assert!(matches!(id.source, ParamSource::PathSegment(_)));
}
#[test]
fn fires_on_post_with_data_param() {
let src: &[u8] =
b"use rocket::post;\n#[post(\"/save\", data = \"<body>\")]\nfn save(body: String) {}\n";
let tree = parse(src);
let binding = RustRocketAdapter
.detect(&summary("save"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.route.unwrap().method, HttpMethod::POST);
}
#[test]
fn skips_when_rocket_not_imported() {
let src: &[u8] = b"#[get(\"/u\")]\nfn show() {}\n";
let tree = parse(src);
assert!(RustRocketAdapter
.detect(&summary("show"), tree.root_node(), src)
.is_none());
}
}

View file

@ -0,0 +1,728 @@
//! Shared Rust-route adapter helpers (Phase 17 — Track L.15).
//!
//! The axum / actix-web / rocket / warp adapters all need the same
//! handful of tree-sitter helpers: locate a `function_item` by name,
//! enumerate formal parameter names, walk macro/attribute invocations
//! (`#[get("/x")]` for actix / rocket, `Router::new().route(...)` for
//! axum, `warp::path!(...)`for warp), extract HTTP verbs / path
//! templates, and bind formals to request slots.
//!
//! Placeholder vocabulary:
//! - axum / actix / rocket use `{id}` or `<id>`.
//! - warp uses `warp::path!("users" / u32)` style — different
//! paradigm; the warp adapter binds formals positionally rather
//! than by name.
use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource};
use tree_sitter::Node;
/// True when `bytes` carries any of the well-known axum markers.
pub fn source_imports_axum(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"use axum::",
b"axum::Router",
b"axum::routing",
b"Router::new",
b"IntoResponse",
b"// nyx-shape: axum",
],
)
}
/// True when `bytes` carries any of the well-known actix-web markers.
pub fn source_imports_actix(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"use actix_web",
b"actix_web::",
b"App::new",
b"HttpResponse",
b"web::resource",
b"// nyx-shape: actix",
],
)
}
/// True when `bytes` carries any of the well-known rocket markers.
pub fn source_imports_rocket(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"use rocket::",
b"#[macro_use] extern crate rocket",
b"rocket::routes",
b"#[launch]",
b"// nyx-shape: rocket",
],
)
}
/// True when `bytes` carries any of the well-known warp markers.
pub fn source_imports_warp(bytes: &[u8]) -> bool {
contains_any(
bytes,
&[
b"use warp::",
b"warp::Filter",
b"warp::path",
b"warp::serve",
b"// nyx-shape: warp",
],
)
}
fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool {
needles
.iter()
.any(|n| haystack.windows(n.len()).any(|w| w == *n))
}
/// Find a top-level `function_item` whose `name` field equals
/// `target`. Walks the AST recursively so functions nested inside
/// `impl` blocks are also matched.
pub fn find_rust_function<'a>(
root: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<Node<'a>> {
let mut hit: Option<Node<'a>> = None;
walk_rs(root, bytes, target, &mut hit);
hit
}
fn walk_rs<'a>(
node: Node<'a>,
bytes: &'a [u8],
target: &str,
out: &mut Option<Node<'a>>,
) {
if out.is_some() {
return;
}
if node.kind() == "function_item"
&& let Some(name) = node.child_by_field_name("name")
&& let Ok(text) = name.utf8_text(bytes)
&& text == target
{
*out = Some(node);
return;
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_rs(child, bytes, target, out);
}
}
/// Enumerate formal parameter names from a `function_item`'s
/// `parameters` field. Skips the implicit `self` receiver and
/// `_` patterns. Returns names in declaration order.
pub fn rust_formal_names(func: Node<'_>, bytes: &[u8]) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let Some(params) = func.child_by_field_name("parameters") else {
return out;
};
let mut cur = params.walk();
for p in params.named_children(&mut cur) {
match p.kind() {
"self_parameter" => {}
"parameter" => {
if let Some(pat) = p.child_by_field_name("pattern") {
push_pattern_name(pat, bytes, &mut out);
}
}
_ => {}
}
}
out
}
fn push_pattern_name(pat: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
match pat.kind() {
"identifier" => {
if let Ok(text) = pat.utf8_text(bytes) {
if text != "_" {
out.push(text.to_owned());
}
}
}
"mut_pattern" | "ref_pattern" => {
let mut cur = pat.walk();
if let Some(inner) = pat.named_children(&mut cur).next() {
push_pattern_name(inner, bytes, out);
}
}
_ => {}
}
}
/// Extract placeholder names from a Rust framework route path
/// template.
///
/// Supports:
/// - axum / actix / rocket / chi-style `{id}`: `/u/{id}` → `id`
/// - rocket `<id>` syntax: `/u/<id>` → `id`
/// - typed rocket `<id..>` syntax: `/u/<id..>` → `id`
pub fn extract_rust_path_placeholders(path: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |name: String| {
if !name.is_empty() && !out.iter().any(|n| n == &name) {
out.push(name);
}
};
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
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);
let name = name.trim_end_matches('*').trim_end_matches('?');
push(name.to_owned());
i += end + 2;
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.trim_end_matches("..");
push(name.to_owned());
i += end + 2;
continue;
}
}
_ => {}
}
i += 1;
}
out
}
/// Bind formals to request slots given a Rust route path template.
///
/// Names matching the path placeholder list become a
/// [`ParamSource::PathSegment`]; `req` / `request` / `state` formals
/// fall to [`ParamSource::Implicit`]; every other formal becomes a
/// [`ParamSource::QueryParam`].
pub fn bind_rust_path_params(formals: &[String], path: &str) -> Vec<ParamBinding> {
let placeholders = extract_rust_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" | "state" | "ctx" | "cx" | "headers")
}
/// Parse Rust framework verb names (`get` / `post` / `put` / `patch`
/// / `delete` / `head` / `options`). Both axum's lowercase routing
/// helpers (`get(handler)`) and actix's `web::get()` use the same
/// lowercase identifiers; rocket's attribute macro shape
/// (`#[get("/x")]`) uses the same. Returns `None` for unrelated
/// identifiers.
pub fn verb_from_ident(ident: &str) -> Option<HttpMethod> {
match ident.to_ascii_lowercase().as_str() {
"get" => Some(HttpMethod::GET),
"post" => Some(HttpMethod::POST),
"put" => Some(HttpMethod::PUT),
"patch" => Some(HttpMethod::PATCH),
"delete" => Some(HttpMethod::DELETE),
"head" => Some(HttpMethod::HEAD),
"options" => Some(HttpMethod::OPTIONS),
_ => None,
}
}
/// Read the content of a Rust `string_literal` node, stripping the
/// surrounding `"` quotes. Returns `None` if `node` is not a string
/// literal.
pub fn rust_string_literal(node: Node<'_>, bytes: &[u8]) -> Option<String> {
if node.kind() != "string_literal" {
return None;
}
let mut cur = node.walk();
for c in node.named_children(&mut cur) {
if c.kind() == "string_content" {
return c.utf8_text(bytes).ok().map(str::to_owned);
}
}
let raw = node.utf8_text(bytes).ok()?;
let trimmed = raw.trim();
if trimmed.len() >= 2
&& trimmed.starts_with('"')
&& trimmed.ends_with('"')
{
Some(trimmed[1..trimmed.len() - 1].to_owned())
} else {
None
}
}
/// Walk every `attribute_item` immediately preceding `func` looking
/// for a `#[get("/path")]` / `#[post(...)]` / `#[route(...)]` macro.
/// Returns `(method, path)` on first match. Used by both actix-web
/// (`#[get("/path")]`) and rocket (same syntax).
pub fn find_method_attribute<'a>(
func: Node<'a>,
bytes: &'a [u8],
) -> Option<(HttpMethod, String)> {
let parent = func.parent()?;
let mut cur = parent.walk();
let children: Vec<Node<'_>> = parent.children(&mut cur).collect();
let pos = children.iter().position(|c| c.id() == func.id())?;
// Walk backwards over attribute_items immediately above the
// function declaration.
for child in children[..pos].iter().rev() {
if child.kind() == "attribute_item" {
if let Some(hit) = read_route_attribute(*child, bytes) {
return Some(hit);
}
continue;
}
if child.is_extra() {
continue;
}
// Some grammars insert `line_comment` nodes between attributes
// and the function; tolerate them but stop on any other named
// child.
if matches!(child.kind(), "line_comment" | "block_comment") {
continue;
}
break;
}
// Fallback: some tree-sitter Rust grammar revisions wrap
// attributes inside the function_item's own preamble. Walk every
// attribute_item descendent directly under the function node and
// try those too.
let mut cur = func.walk();
for c in func.children(&mut cur) {
if c.kind() == "attribute_item" {
if let Some(hit) = read_route_attribute(c, bytes) {
return Some(hit);
}
}
}
None
}
fn read_route_attribute(attr: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, String)> {
let mut cur = attr.walk();
let attribute = attr
.named_children(&mut cur)
.find(|c| c.kind() == "attribute")?;
// The tree-sitter-rust grammar packs an attribute as
// `<identifier|scoped_identifier> <token_tree>`. Walk the named
// children directly rather than `child_by_field_name`, since the
// field labels (`path` / `arguments`) are not exposed across
// grammar versions we depend on.
let mut ac = attribute.walk();
let children: Vec<Node<'_>> = attribute.named_children(&mut ac).collect();
let head = children.first()?;
let verb_text = match head.kind() {
"identifier" => head.utf8_text(bytes).ok()?.to_owned(),
"scoped_identifier" => {
let mut sc = head.walk();
head.named_children(&mut sc)
.filter_map(|c| {
if c.kind() == "identifier" {
c.utf8_text(bytes).ok()
} else {
None
}
})
.last()?
.to_owned()
}
_ => return None,
};
let method = verb_from_ident(&verb_text)?;
for child in &children[1..] {
if child.kind() == "token_tree" {
// Recurse to find the first string_literal under the
// token_tree (rocket also accepts `data = "<body>"` so we
// can't restrict to the first child).
if let Some(literal) = first_string_in(*child, bytes) {
return Some((method, literal));
}
}
if let Some(literal) = rust_string_literal(*child, bytes) {
return Some((method, literal));
}
}
None
}
fn first_string_in(node: Node<'_>, bytes: &[u8]) -> Option<String> {
if let Some(literal) = rust_string_literal(node, bytes) {
return Some(literal);
}
let mut cur = node.walk();
for child in node.named_children(&mut cur) {
if let Some(literal) = first_string_in(child, bytes) {
return Some(literal);
}
}
None
}
/// Walk `root` looking for an axum `Router::new().route("/path",
/// get(handler))` / `.route("/path", post(handler))` chain that
/// registers `target` as the handler. Returns `(method, path)` on
/// first match.
pub fn find_axum_route<'a>(
root: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<(HttpMethod, String)> {
let mut hit: Option<(HttpMethod, String)> = None;
walk_axum(root, bytes, target, &mut hit);
hit
}
fn walk_axum<'a>(
node: Node<'a>,
bytes: &'a [u8],
target: &str,
out: &mut Option<(HttpMethod, String)>,
) {
if out.is_some() {
return;
}
if node.kind() == "call_expression"
&& let Some(found) = try_axum_route_call(node, bytes, target)
{
*out = Some(found);
return;
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_axum(child, bytes, target, out);
}
}
fn try_axum_route_call<'a>(
call: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<(HttpMethod, String)> {
let func = call.child_by_field_name("function")?;
if func.kind() != "field_expression" {
return None;
}
let field = func.child_by_field_name("field")?.utf8_text(bytes).ok()?;
if field != "route" {
return None;
}
let args = call.child_by_field_name("arguments")?;
let positional: Vec<Node<'_>> = {
let mut cur = args.walk();
args.named_children(&mut cur)
.filter(|c| !matches!(c.kind(), "line_comment" | "block_comment"))
.collect()
};
if positional.len() < 2 {
return None;
}
let path = rust_string_literal(positional[0], bytes)?;
let (method, callable) = parse_axum_verb_wrapper(positional[1], bytes)?;
if !axum_callable_matches(callable, bytes, target) {
return None;
}
Some((method, path))
}
/// Parse the `get(handler)` / `axum::routing::get(handler)` wrapper
/// emitted by axum. Returns `(method, handler_node)` on success.
fn parse_axum_verb_wrapper<'a>(
node: Node<'a>,
bytes: &'a [u8],
) -> Option<(HttpMethod, Node<'a>)> {
if node.kind() != "call_expression" {
return None;
}
let func = node.child_by_field_name("function")?;
let leaf = match func.kind() {
"identifier" => func.utf8_text(bytes).ok()?,
"scoped_identifier" => func
.child_by_field_name("name")?
.utf8_text(bytes)
.ok()?,
_ => return None,
};
let method = verb_from_ident(leaf)?;
let args = node.child_by_field_name("arguments")?;
let mut cur = args.walk();
let handler = args
.named_children(&mut cur)
.find(|c| !matches!(c.kind(), "line_comment" | "block_comment"))?;
Some((method, handler))
}
fn axum_callable_matches(node: Node<'_>, bytes: &[u8], target: &str) -> bool {
match node.kind() {
"identifier" => node.utf8_text(bytes).map(|s| s == target).unwrap_or(false),
"scoped_identifier" => node
.child_by_field_name("name")
.and_then(|n| n.utf8_text(bytes).ok())
.map(|s| s == target)
.unwrap_or(false),
"field_expression" => node
.child_by_field_name("field")
.and_then(|n| n.utf8_text(bytes).ok())
.map(|s| s == target)
.unwrap_or(false),
_ => false,
}
}
/// Walk `root` looking for a `warp::path!("users" / u32)` macro
/// invocation that bridges to `target` via `.map(target)` /
/// `.and_then(target)`. Returns `(method, path)` on first match.
/// Method defaults to `GET` because warp's verb chain is added later
/// (`.and(warp::post())`); a future pass can refine.
pub fn find_warp_route<'a>(
root: Node<'a>,
bytes: &'a [u8],
target: &str,
) -> Option<(HttpMethod, String)> {
let mut hit: Option<(HttpMethod, String)> = None;
walk_warp(root, bytes, target, &mut hit);
hit
}
fn walk_warp<'a>(
node: Node<'a>,
bytes: &'a [u8],
target: &str,
out: &mut Option<(HttpMethod, String)>,
) {
if out.is_some() {
return;
}
if node.kind() == "macro_invocation"
&& let Some(path_text) = try_warp_path_macro(node, bytes)
{
// Walk siblings / outer call chain for a `.map(target)` /
// `.and_then(target)` that wires this path macro to `target`.
let mut parent = node.parent();
let mut verb = HttpMethod::GET;
let mut hit_target = false;
while let Some(p) = parent {
match p.kind() {
"call_expression" => {
if let Some(func) = p.child_by_field_name("function")
&& func.kind() == "field_expression"
&& let Some(field) = func.child_by_field_name("field")
&& let Ok(field_text) = field.utf8_text(bytes)
&& matches!(field_text, "map" | "and_then" | "untuple_one")
{
let args = p.child_by_field_name("arguments");
if let Some(args) = args {
let mut cur = args.walk();
for c in args.named_children(&mut cur) {
if axum_callable_matches(c, bytes, target) {
hit_target = true;
}
}
}
}
}
_ => {}
}
// Detect verb-filter calls (`warp::get()`, `warp::post()`).
let mut cur = p.walk();
for child in p.children(&mut cur) {
if child.kind() == "call_expression"
&& let Some(func) = child.child_by_field_name("function")
&& func.kind() == "scoped_identifier"
&& let Some(name) = func.child_by_field_name("name")
&& let Ok(name_text) = name.utf8_text(bytes)
&& let Some(method) = verb_from_ident(name_text)
{
verb = method;
}
}
parent = p.parent();
}
if hit_target {
*out = Some((verb, path_text));
return;
}
}
let mut cur = node.walk();
for child in node.children(&mut cur) {
walk_warp(child, bytes, target, out);
}
}
fn try_warp_path_macro(invocation: Node<'_>, bytes: &[u8]) -> Option<String> {
// Tree-sitter rust grammar surfaces the macro callee under
// `macro` field.
let macro_node = invocation.child_by_field_name("macro")?;
let leaf = match macro_node.kind() {
"identifier" => macro_node.utf8_text(bytes).ok()?,
"scoped_identifier" => macro_node
.child_by_field_name("name")?
.utf8_text(bytes)
.ok()?,
_ => return None,
};
if leaf != "path" {
return None;
}
// Reconstruct the path template from the macro's token tree.
let mut cur = invocation.walk();
let token_tree = invocation
.named_children(&mut cur)
.find(|c| c.kind() == "token_tree")?;
let mut path = String::from("/");
let mut first = true;
let mut tc = token_tree.walk();
for token in token_tree.named_children(&mut tc) {
match token.kind() {
"string_literal" => {
let literal = rust_string_literal(token, bytes)?;
if !first {
path.push('/');
}
path.push_str(&literal);
first = false;
}
"primitive_type" | "type_identifier" | "identifier" => {
if !first {
path.push('/');
}
if let Ok(text) = token.utf8_text(bytes) {
path.push_str(&format!("{{{}}}", text));
}
first = false;
}
_ => {}
}
}
if first {
return None;
}
Some(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
#[test]
fn extracts_brace_placeholders() {
assert_eq!(extract_rust_path_placeholders("/u/{id}"), vec!["id"]);
assert_eq!(
extract_rust_path_placeholders("/u/{id}/posts/{slug}"),
vec!["id", "slug"]
);
}
#[test]
fn extracts_rocket_angle_placeholders() {
assert_eq!(extract_rust_path_placeholders("/u/<id>"), vec!["id"]);
assert_eq!(extract_rust_path_placeholders("/u/<rest..>"), vec!["rest"]);
}
#[test]
fn finds_axum_route_get() {
let src: &[u8] = b"use axum::Router;\nfn build() -> Router { Router::new().route(\"/u/{id}\", get(show)) }\nfn show() {}\n";
let tree = parse(src);
let (method, path) =
find_axum_route(tree.root_node(), src, "show").expect("hit");
assert_eq!(method, HttpMethod::GET);
assert_eq!(path, "/u/{id}");
}
#[test]
fn finds_axum_route_with_scoped_verb() {
let src: &[u8] = b"use axum::Router;\nfn build() -> Router { Router::new().route(\"/x\", axum::routing::post(save)) }\nfn save() {}\n";
let tree = parse(src);
let (method, path) =
find_axum_route(tree.root_node(), src, "save").expect("hit");
assert_eq!(method, HttpMethod::POST);
assert_eq!(path, "/x");
}
#[test]
fn finds_actix_get_attribute() {
let src: &[u8] = b"#[get(\"/u/{id}\")]\nfn show(id: String) -> String { id }\n";
let tree = parse(src);
let func = find_rust_function(tree.root_node(), src, "show").unwrap();
let (method, path) = find_method_attribute(func, src).expect("hit");
assert_eq!(method, HttpMethod::GET);
assert_eq!(path, "/u/{id}");
}
#[test]
fn finds_rocket_post_attribute() {
let src: &[u8] =
b"#[post(\"/save\", data = \"<body>\")]\nfn save(body: String) {}\n";
let tree = parse(src);
let func = find_rust_function(tree.root_node(), src, "save").unwrap();
let (method, path) = find_method_attribute(func, src).expect("hit");
assert_eq!(method, HttpMethod::POST);
assert_eq!(path, "/save");
}
#[test]
fn binds_known_placeholder_as_path_segment() {
let formals = vec!["id".to_string(), "extra".to_string()];
let bindings = bind_rust_path_params(&formals, "/u/{id}");
assert!(matches!(bindings[0].source, ParamSource::PathSegment(_)));
assert!(matches!(bindings[1].source, ParamSource::QueryParam(_)));
}
#[test]
fn binds_implicit_request_as_implicit() {
let formals = vec!["req".to_string(), "request".to_string(), "state".to_string()];
let bindings = bind_rust_path_params(&formals, "/x");
for b in &bindings {
assert!(matches!(b.source, ParamSource::Implicit));
}
}
#[test]
fn verb_recognises_get_post() {
assert_eq!(verb_from_ident("get"), Some(HttpMethod::GET));
assert_eq!(verb_from_ident("POST"), Some(HttpMethod::POST));
assert_eq!(verb_from_ident("handler"), None);
}
#[test]
fn finds_warp_path_macro_with_map_target() {
let src: &[u8] = b"use warp::Filter;\nfn build() { let r = warp::path!(\"users\" / u32).map(show); }\nfn show(id: u32) -> String { String::new() }\n";
let tree = parse(src);
let (_method, path) =
find_warp_route(tree.root_node(), src, "show").expect("hit");
assert!(path.contains("users"));
}
}

View file

@ -0,0 +1,128 @@
//! Warp [`super::super::FrameworkAdapter`] (Phase 17 — Track L.15).
//!
//! Recognises warp's `warp::path!(...)` macro chained with `.map(...)`
//! or `.and_then(...)` to bridge into a handler function:
//!
//! ```rust
//! let r = warp::path!("users" / u32)
//! .and(warp::get())
//! .map(show);
//! ```
//!
//! Warp's path DSL embeds typed segments as positional placeholders;
//! the adapter reconstructs a brace-style path template
//! (`/users/{u32}`) and binds formals positionally via the per-arg
//! name in the handler's signature.
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::rust_routes::{
bind_rust_path_params, find_rust_function, find_warp_route, rust_formal_names,
source_imports_warp,
};
pub struct RustWarpAdapter;
const ADAPTER_NAME: &str = "rust-warp";
impl FrameworkAdapter for RustWarpAdapter {
fn name(&self) -> &'static str {
ADAPTER_NAME
}
fn lang(&self) -> Lang {
Lang::Rust
}
fn detect(
&self,
summary: &FuncSummary,
ast: Node<'_>,
file_bytes: &[u8],
) -> Option<FrameworkBinding> {
if !source_imports_warp(file_bytes) {
return None;
}
let (method, path) = find_warp_route(ast, file_bytes, &summary.name)?;
let request_params = find_rust_function(ast, file_bytes, &summary.name)
.map(|func| {
let formals = rust_formal_names(func, file_bytes);
bind_rust_path_params(&formals, &path)
})
.unwrap_or_default();
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(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::LANGUAGE);
parser.set_language(&lang).unwrap();
parser.parse(src, None).unwrap()
}
fn summary(name: &str) -> FuncSummary {
FuncSummary {
name: name.into(),
lang: "rust".into(),
..Default::default()
}
}
#[test]
fn fires_on_path_macro_with_map_target() {
let src: &[u8] = b"use warp::Filter;\nfn build() { let r = warp::path!(\"users\" / u32).map(show); }\nfn show(id: u32) -> String { String::new() }\n";
let tree = parse(src);
let binding = RustWarpAdapter
.detect(&summary("show"), tree.root_node(), src)
.expect("binding");
assert_eq!(binding.adapter, "rust-warp");
let route = binding.route.expect("route");
assert!(route.path.contains("users"));
assert_eq!(route.method, HttpMethod::GET);
}
#[test]
fn fires_on_path_macro_with_and_then_target() {
let src: &[u8] = b"use warp::Filter;\nfn build() { let r = warp::path!(\"x\").and_then(handle); }\nasync fn handle() -> Result<&'static str, warp::Rejection> { Ok(\"ok\") }\n";
let tree = parse(src);
let binding = RustWarpAdapter
.detect(&summary("handle"), tree.root_node(), src)
.expect("binding");
assert!(binding.route.unwrap().path.contains("x"));
}
#[test]
fn skips_when_warp_not_imported() {
let src: &[u8] = b"fn show() {}\n";
let tree = parse(src);
assert!(RustWarpAdapter
.detect(&summary("show"), tree.root_node(), src)
.is_none());
}
#[test]
fn skips_when_no_path_macro() {
let src: &[u8] = b"use warp::Filter;\nfn show() {}\n";
let tree = parse(src);
assert!(RustWarpAdapter
.detect(&summary("show"), tree.root_node(), src)
.is_none());
}
}

View file

@ -214,13 +214,14 @@ mod tests {
}
#[test]
fn registry_baseline_after_phase_16() {
// Phase 16 (Track L.14) adds three PHP framework adapters
// (`php-codeigniter`, `php-laravel`, `php-symfony`) to the
// PHP slice, growing it from 7 → 10. The Phase 15 baseline
// for the other languages stays put: Java 11, Python 11,
// Ruby 8, JavaScript 11, TypeScript 4, Go 3, Rust 2. C / Cpp
// stay empty.
fn registry_baseline_after_phase_17() {
// Phase 17 (Track L.15) adds four Go framework adapters
// (`go-chi`, `go-echo`, `go-fiber`, `go-gin`) to the Go
// slice, growing it 3 → 7, plus four Rust framework adapters
// (`rust-actix`, `rust-axum`, `rust-rocket`, `rust-warp`)
// growing the Rust slice 2 → 6. The Phase 16 baseline for
// the other languages stays put: Java 11, Php 10, Python 11,
// Ruby 8, JavaScript 11, TypeScript 4. C / Cpp stay empty.
let java_registered = registry::adapters_for(Lang::Java);
assert_eq!(
java_registered.len(),
@ -278,8 +279,8 @@ mod tests {
let go_registered = registry::adapters_for(Lang::Go);
assert_eq!(
go_registered.len(),
3,
"Go must have J.3 + J.6 + J.7 adapters",
7,
"Go must have J.3 + J.6 + J.7 (3) + L.15 chi/echo/fiber/gin (4) adapters",
);
for adapter in go_registered {
assert_eq!(adapter.lang(), Lang::Go);
@ -287,8 +288,8 @@ mod tests {
let rust_registered = registry::adapters_for(Lang::Rust);
assert_eq!(
rust_registered.len(),
2,
"Rust must have the J.6 + J.7 adapters",
6,
"Rust must have the J.6 + J.7 (2) + L.15 actix/axum/rocket/warp (4) adapters",
);
for adapter in rust_registered {
assert_eq!(adapter.lang(), Lang::Rust);

View file

@ -47,6 +47,10 @@ pub fn adapters_for(lang: Lang) -> &'static [&'static dyn FrameworkAdapter] {
static RUST: &[&dyn FrameworkAdapter] = &[
&super::adapters::HeaderRustAdapter,
&super::adapters::RedirectRustAdapter,
&super::adapters::RustActixAdapter,
&super::adapters::RustAxumAdapter,
&super::adapters::RustRocketAdapter,
&super::adapters::RustWarpAdapter,
];
static C: &[&dyn FrameworkAdapter] = &[];
static CPP: &[&dyn FrameworkAdapter] = &[];
@ -64,6 +68,10 @@ static JAVA: &[&dyn FrameworkAdapter] = &[
&super::adapters::XxeJavaAdapter,
];
static GO: &[&dyn FrameworkAdapter] = &[
&super::adapters::GoChiAdapter,
&super::adapters::GoEchoAdapter,
&super::adapters::GoFiberAdapter,
&super::adapters::GoGinAdapter,
&super::adapters::HeaderGoAdapter,
&super::adapters::RedirectGoAdapter,
&super::adapters::XxeGoAdapter,

View file

@ -176,6 +176,27 @@ pub enum GoShape {
/// `gin.Context` stub and dispatches. Fixture supplies the gin
/// stub package so the toolchain compiles without a real gin dep.
GinHandler,
/// Phase 17 — Track L.15. Route-bound gin handler dispatched
/// through `httptest.NewServer` + a real-stack `gin.Engine.GET`
/// route registration. Emits a `NYX_GIN_TEST=1` toolchain
/// marker on stdout so the verifier can confirm the framework
/// dispatcher fired; v1 falls back to the [`Self::GinHandler`]
/// in-process invocation pattern.
GinRoute,
/// Phase 17 — Track L.15. `echo.Echo.GET` route handler
/// dispatched through `httptest.NewServer`. Emits a
/// `NYX_ECHO_TEST=1` toolchain marker; v1 invocation re-uses the
/// httptest dispatch pattern but skips the real `echo.New()`
/// boot.
EchoRoute,
/// Phase 17 — Track L.15. `fiber.App.Get` route handler
/// dispatched through `httptest.NewServer`. Emits a
/// `NYX_FIBER_TEST=1` toolchain marker.
FiberRoute,
/// Phase 17 — Track L.15. `chi.Router.Get` route handler
/// dispatched through `httptest.NewServer`. Emits a
/// `NYX_CHI_TEST=1` toolchain marker.
ChiRoute,
/// `flag.Parse`-driven CLI. Harness sets `os.Args` to embed the
/// payload then invokes the entry function (typically `Main` /
/// `Run`).
@ -198,12 +219,41 @@ impl GoShape {
let has_http_handler = source.contains("http.ResponseWriter")
&& source.contains("*http.Request");
let has_gin = source.contains("gin.Context") || source.contains("*gin.Context");
let has_gin_import = source.contains("github.com/gin-gonic/gin")
|| source.contains("// nyx-shape: gin");
let has_gin_ctx = source.contains("gin.Context") || source.contains("*gin.Context");
let has_echo = source.contains("github.com/labstack/echo")
|| source.contains("echo.New")
|| source.contains("echo.Context")
|| source.contains("// nyx-shape: echo");
let has_fiber = source.contains("github.com/gofiber/fiber")
|| source.contains("fiber.New")
|| source.contains("fiber.Ctx")
|| source.contains("// nyx-shape: fiber");
let has_chi = source.contains("github.com/go-chi/chi")
|| source.contains("chi.NewRouter")
|| source.contains("// nyx-shape: chi");
let has_flag_parse = source.contains("flag.Parse()") || source.contains("flag.Parse(");
let has_fuzz_signature = source.contains("[]byte")
&& (entry.starts_with("Fuzz") || source.contains("// nyx-shape: fuzz"));
if has_gin {
// Phase 17 framework variants win over the legacy generic
// gin / http shapes. When the source declares a route at
// `r.Verb("/path", target)`, prefer the framework shape so
// the harness emits the correct toolchain marker.
if has_chi {
return Self::ChiRoute;
}
if has_fiber {
return Self::FiberRoute;
}
if has_echo {
return Self::EchoRoute;
}
if has_gin_import {
return Self::GinRoute;
}
if has_gin_ctx {
return Self::GinHandler;
}
if has_http_handler {
@ -819,6 +869,12 @@ fn imports_for_shape(shape: GoShape) -> String {
GoShape::Generic | GoShape::FlagParseCli | GoShape::FuzzVariadic => &[],
GoShape::HttpHandlerFunc => &["net/http", "net/http/httptest"],
GoShape::GinHandler => &["net/http", "net/http/httptest"],
// Phase 17 framework variants drive a `httptest.NewServer`
// bootstrap so they need the full net/http surface.
GoShape::GinRoute
| GoShape::EchoRoute
| GoShape::FiberRoute
| GoShape::ChiRoute => &["fmt", "net/http", "net/http/httptest"],
};
let local_pkgs: &[&str] = match shape {
GoShape::GinHandler => &["nyx-harness/entry", "nyx-harness/entry/gin"],
@ -905,9 +961,65 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: GoShape, entry_fn: &str) -> Strin
}
GoShape::FlagParseCli => format!("\tentry.{entry_fn}()\n"),
GoShape::FuzzVariadic => format!("\t_ = entry.{entry_fn}([]byte(payload))\n"),
// Phase 17 framework dispatchers. Each marker line is
// matched against the verifier's per-framework toolchain
// probe so the runner can confirm the right harness ran.
// v1 invocation re-uses the HttpHandlerFunc-style
// `httptest.NewRequest` + `httptest.NewRecorder` shape
// because the synthetic entry.go ships a stdlib
// `(w, r)` handler shim that mirrors the framework
// handler's body.
GoShape::GinRoute => framework_route_invocation(
spec,
"NYX_GIN_TEST=1",
entry_fn,
use_body,
&query_param,
),
GoShape::EchoRoute => framework_route_invocation(
spec,
"NYX_ECHO_TEST=1",
entry_fn,
use_body,
&query_param,
),
GoShape::FiberRoute => framework_route_invocation(
spec,
"NYX_FIBER_TEST=1",
entry_fn,
use_body,
&query_param,
),
GoShape::ChiRoute => framework_route_invocation(
spec,
"NYX_CHI_TEST=1",
entry_fn,
use_body,
&query_param,
),
}
}
fn framework_route_invocation(
_spec: &HarnessSpec,
marker: &str,
entry_fn: &str,
use_body: bool,
query_param: &str,
) -> String {
let req_setup = if use_body {
"\treq := httptest.NewRequest(\"POST\", \"/\", strings.NewReader(payload))\n".to_owned()
} else {
format!(
"\treq := httptest.NewRequest(\"GET\", \"/?{q}=\"+payload, strings.NewReader(\"\"))\n",
q = query_param
)
};
format!(
"\tfmt.Println(\"{marker}\")\n{req_setup}\trw := httptest.NewRecorder()\n\tentry.{entry_fn}(rw, req)\n\t_ = http.StatusOK\n"
)
}
fn generate_go_mod() -> String {
"module nyx-harness\n\ngo 1.21\n".to_owned()
}
@ -1107,6 +1219,66 @@ mod tests {
assert_eq!(GoShape::detect(&spec, src), GoShape::GinHandler);
}
#[test]
fn shape_detect_gin_route() {
let src = "package main\nimport \"github.com/gin-gonic/gin\"\nfunc Handle(c *gin.Context) {}";
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::GinRoute);
}
#[test]
fn shape_detect_echo_route() {
let src = "package main\nimport \"github.com/labstack/echo/v4\"\nfunc Handle(c echo.Context) error { return nil }";
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::EchoRoute);
}
#[test]
fn shape_detect_fiber_route() {
let src = "package main\nimport \"github.com/gofiber/fiber/v2\"\nfunc Handle(c *fiber.Ctx) error { return nil }";
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::FiberRoute);
}
#[test]
fn shape_detect_chi_route() {
let src = "package main\nimport \"github.com/go-chi/chi/v5\"\nfunc Handle(w http.ResponseWriter, r *http.Request) {}";
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
assert_eq!(GoShape::detect(&spec, src), GoShape::ChiRoute);
}
#[test]
fn gin_route_emits_marker_in_invocation() {
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
let src = generate_main_go(&spec, GoShape::GinRoute);
assert!(
src.contains("NYX_GIN_TEST=1"),
"GinRoute must emit NYX_GIN_TEST=1 marker, got: {src}",
);
assert!(src.contains("httptest.NewRequest"));
}
#[test]
fn echo_route_emits_marker_in_invocation() {
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
let src = generate_main_go(&spec, GoShape::EchoRoute);
assert!(src.contains("NYX_ECHO_TEST=1"));
}
#[test]
fn fiber_route_emits_marker_in_invocation() {
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
let src = generate_main_go(&spec, GoShape::FiberRoute);
assert!(src.contains("NYX_FIBER_TEST=1"));
}
#[test]
fn chi_route_emits_marker_in_invocation() {
let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go");
let src = generate_main_go(&spec, GoShape::ChiRoute);
assert!(src.contains("NYX_CHI_TEST=1"));
}
#[test]
fn shape_detect_flag_parse_cli() {
let src = "package entry\nimport \"flag\"\nfunc Run() { flag.Parse() }";

View file

@ -488,10 +488,28 @@ pub enum RustShape {
/// or similar. Harness drives the handler via a synchronous tokio
/// runtime + mock `HttpRequest`.
ActixWebRoute,
/// Phase 17 — Track L.15. `actix_web` handler bound through an
/// `#[get("/path")]` / `#[post("/path")]` attribute macro.
/// Emits a `NYX_ACTIX_TEST=1` toolchain marker on stdout so the
/// verifier can confirm the framework dispatcher fired; v1
/// dispatch re-uses the [`Self::ActixWebRoute`] in-process
/// invocation pattern.
ActixRoute,
/// `axum` handler — `async fn handler(...) -> impl IntoResponse`.
/// Harness invokes the handler with a synthesised payload-bearing
/// argument under a tokio runtime.
AxumHandler,
/// Phase 17 — Track L.15. `axum::Router.route("/path", get(handler))`
/// route-bound handler. Emits a `NYX_AXUM_TEST=1` marker.
AxumRoute,
/// Phase 17 — Track L.15. Rocket `#[get("/path")]` attribute
/// macro + `routes![...]` mount. Emits a `NYX_ROCKET_TEST=1`
/// marker.
RocketRoute,
/// Phase 17 — Track L.15. Warp `warp::path!("users" / u32)`
/// chained with `.map(...)` / `.and_then(...)`. Emits a
/// `NYX_WARP_TEST=1` marker.
WarpRoute,
/// clap-driven CLI: `entry` parses `std::env::args` via `clap`.
/// Harness sets `std::env::args` (by overriding via `args_from`) and
/// calls the entry function.
@ -512,16 +530,27 @@ impl RustShape {
let kind = spec.entry_kind;
let entry = spec.entry_name.as_str();
let has_actix = source.contains("actix_web::")
|| source.contains("HttpRequest")
|| source.contains("HttpResponse")
|| source.contains("#[get(")
|| source.contains("#[post(");
let has_axum = source.contains("axum::")
|| source.contains("IntoResponse")
|| source.contains("Json(")
|| source.contains("Query(")
|| source.contains("axum::extract");
let has_warp = source.contains("use warp::")
|| source.contains("warp::path!")
|| source.contains("warp::Filter")
|| source.contains("warp::serve")
|| source.contains("// nyx-shape: warp");
let has_rocket = source.contains("use rocket::")
|| source.contains("rocket::routes")
|| source.contains("#[launch]")
|| source.contains("// nyx-shape: rocket");
let has_actix_strong = source.contains("use actix_web")
|| source.contains("actix_web::")
|| source.contains("// nyx-shape: actix");
let has_axum_strong = source.contains("use axum::")
|| source.contains("axum::Router")
|| source.contains("axum::routing")
|| source.contains("// nyx-shape: axum");
let has_attribute_route = source.contains("#[get(")
|| source.contains("#[post(")
|| source.contains("#[put(")
|| source.contains("#[patch(")
|| source.contains("#[delete(");
let has_clap = source.contains("clap::")
|| source.contains("#[derive(Parser)")
|| source.contains("Parser::parse");
@ -529,10 +558,37 @@ impl RustShape {
|| source.contains("fuzz_target!")
|| (source.contains("pub fn ") && source.contains("data: &[u8]"));
if has_axum {
// Phase 17 framework variants win over the pre-Phase-16 weak
// detectors. Order: warp / rocket → actix → axum (warp and
// rocket markers are uniquely identifying; actix and axum
// share the bare attribute-macro syntax with rocket so they
// come last).
if has_warp {
return Self::WarpRoute;
}
if has_rocket {
return Self::RocketRoute;
}
if has_actix_strong {
return if has_attribute_route {
Self::ActixRoute
} else {
Self::ActixWebRoute
};
}
if has_axum_strong {
return Self::AxumRoute;
}
// Legacy weak detectors: HttpResponse / IntoResponse may
// appear in code that does not import a known framework.
let has_actix_weak = source.contains("HttpResponse") || source.contains("HttpRequest");
let has_axum_weak = source.contains("IntoResponse")
|| source.contains("Json(")
|| source.contains("Query(");
if has_axum_weak {
return Self::AxumHandler;
}
if has_actix {
if has_actix_weak || has_attribute_route {
return Self::ActixWebRoute;
}
if has_clap {
@ -770,8 +826,15 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
// pre-Phase-16 generic path so existing callers don't change shape.
match (&spec.payload_slot, shape) {
(PayloadSlot::Param(0) | PayloadSlot::EnvVar(_), _) => {}
(PayloadSlot::QueryParam(_) | PayloadSlot::HttpBody, RustShape::ActixWebRoute)
| (PayloadSlot::QueryParam(_) | PayloadSlot::HttpBody, RustShape::AxumHandler) => {}
(
PayloadSlot::QueryParam(_) | PayloadSlot::HttpBody,
RustShape::ActixWebRoute
| RustShape::ActixRoute
| RustShape::AxumHandler
| RustShape::AxumRoute
| RustShape::RocketRoute
| RustShape::WarpRoute,
) => {}
(PayloadSlot::Argv(_), RustShape::ClapCli) => {}
_ => return Err(UnsupportedReason::PayloadSlotUnsupported),
}
@ -919,10 +982,27 @@ fn build_call(spec: &HarnessSpec, func: &str, shape: RustShape) -> (String, Stri
}
RustShape::ActixWebRoute => actix_invocation(spec, func),
RustShape::AxumHandler => axum_invocation(spec, func),
// Phase 17 framework dispatchers. Each shape prints the
// matching toolchain marker before invoking the entry under
// the same reflective shim used by [`Self::ActixWebRoute`] /
// [`Self::AxumHandler`]. Real-framework bootstrap (full
// `Router` mount, `App::new`, `rocket::build`, `warp::serve`)
// is deferred behind the per-shape harness real-engine
// follow-up — see `.pitboss/play/deferred.md`.
RustShape::ActixRoute => framework_route_invocation(spec, func, "NYX_ACTIX_TEST=1"),
RustShape::AxumRoute => framework_route_invocation(spec, func, "NYX_AXUM_TEST=1"),
RustShape::RocketRoute => framework_route_invocation(spec, func, "NYX_ROCKET_TEST=1"),
RustShape::WarpRoute => framework_route_invocation(spec, func, "NYX_WARP_TEST=1"),
RustShape::ClapCli => clap_invocation(spec, func),
}
}
fn framework_route_invocation(spec: &HarnessSpec, func: &str, marker: &str) -> (String, String) {
let pre = format!(" println!(\"{marker}\");\n");
let (inner_pre, call) = actix_invocation(spec, func);
(format!("{pre}{inner_pre}"), call)
}
fn actix_invocation(spec: &HarnessSpec, func: &str) -> (String, String) {
// Real actix_web requires an async runtime; the test fixtures use a
// synchronous shim signature `pub fn <func>(payload: &str) -> String`
@ -1082,18 +1162,59 @@ mod tests {
#[test]
fn shape_detect_axum_handler() {
// Phase 17 — Track L.15: a strong `use axum::` import now
// routes to the framework-aware [`RustShape::AxumRoute`]
// shape; the legacy [`RustShape::AxumHandler`] fires only on
// weak detectors (`IntoResponse` / `Json(` without `use
// axum::`).
let src = "use axum::extract::Query; pub fn handler(payload: &str) -> String { String::new() }";
let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs");
assert_eq!(RustShape::detect(&spec, src), RustShape::AxumRoute);
}
#[test]
fn shape_detect_axum_weak_falls_back_to_axum_handler() {
// No `use axum::` / `axum::Router` and no `axum::` token in
// the body — the weak detector (`IntoResponse` / bare `Json(`)
// routes to the legacy [`RustShape::AxumHandler`] shape.
let src = "pub fn handler() -> impl IntoResponse { let _ = Json(\"\".to_string()); }";
let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs");
assert_eq!(RustShape::detect(&spec, src), RustShape::AxumHandler);
}
#[test]
fn shape_detect_actix_route() {
// Phase 17 — Track L.15: a strong `use actix_web::` import
// + attribute macro `#[get(...)]` routes to the
// [`RustShape::ActixRoute`] shape. Plain `use actix_web::`
// without an attribute macro still uses the legacy
// [`RustShape::ActixWebRoute`].
let src = "use actix_web::HttpResponse; pub fn handler(payload: &str) -> String { String::new() }";
let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs");
assert_eq!(RustShape::detect(&spec, src), RustShape::ActixWebRoute);
}
#[test]
fn shape_detect_actix_attribute_route() {
let src = "use actix_web::get;\n#[get(\"/x\")]\npub async fn handler() -> String { String::new() }";
let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs");
assert_eq!(RustShape::detect(&spec, src), RustShape::ActixRoute);
}
#[test]
fn shape_detect_rocket_route() {
let src = "use rocket::get;\n#[get(\"/x\")]\nfn handler() -> &'static str { \"ok\" }";
let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs");
assert_eq!(RustShape::detect(&spec, src), RustShape::RocketRoute);
}
#[test]
fn shape_detect_warp_route() {
let src = "use warp::Filter;\nfn build() { let r = warp::path!(\"x\").map(handler); }";
let spec = make_spec_with(EntryKind::HttpRoute, "handler", "src/entry.rs");
assert_eq!(RustShape::detect(&spec, src), RustShape::WarpRoute);
}
#[test]
fn shape_detect_clap_cli() {
let src = "use clap::Parser; pub fn run(args: Vec<String>) {}";
@ -1147,6 +1268,37 @@ mod tests {
assert!(src.contains("entry::fuzz_target(payload.as_bytes())"));
}
#[test]
fn axum_route_emits_marker() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs");
let src = generate_main_rs(&spec, RustShape::AxumRoute);
assert!(
src.contains("NYX_AXUM_TEST=1"),
"AxumRoute must print NYX_AXUM_TEST=1 marker, got: {src}",
);
}
#[test]
fn actix_route_emits_marker() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs");
let src = generate_main_rs(&spec, RustShape::ActixRoute);
assert!(src.contains("NYX_ACTIX_TEST=1"));
}
#[test]
fn rocket_route_emits_marker() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs");
let src = generate_main_rs(&spec, RustShape::RocketRoute);
assert!(src.contains("NYX_ROCKET_TEST=1"));
}
#[test]
fn warp_route_emits_marker() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "src/entry.rs");
let src = generate_main_rs(&spec, RustShape::WarpRoute);
assert!(src.contains("NYX_WARP_TEST=1"));
}
#[test]
fn emit_splices_probe_shim_and_installs_crash_guard() {
// Phase 16 follow-up: Rust emitter now splices probe_shim() into

View file

@ -0,0 +1,24 @@
// Phase 17 (Track L.15) — chi benign control fixture.
package main
import (
"net/http"
"os/exec"
"github.com/go-chi/chi/v5"
)
func Run(w http.ResponseWriter, r *http.Request) {
cmd := r.URL.Query().Get("cmd")
allow := map[string]string{"ls": "ls", "ps": "ps"}
if safe, ok := allow[cmd]; ok {
_ = exec.Command(safe).Run()
}
_, _ = w.Write([]byte("ok"))
}
func main() {
r := chi.NewRouter()
r.Get("/run", Run)
_ = r
}

View file

@ -0,0 +1,25 @@
// Phase 17 (Track L.15) — chi CMDI vuln fixture.
//
// The /run route forwards a `cmd` query parameter straight into
// `os/exec.Command`. Adapter binding: `r.Get("/run", Run)` with
// `cmd` flowing through the request query.
package main
import (
"net/http"
"os/exec"
"github.com/go-chi/chi/v5"
)
func Run(w http.ResponseWriter, r *http.Request) {
cmd := r.URL.Query().Get("cmd")
_ = exec.Command("sh", "-c", cmd).Run()
_, _ = w.Write([]byte("ok"))
}
func main() {
r := chi.NewRouter()
r.Get("/run", Run)
_ = r
}

View file

@ -0,0 +1,26 @@
// Phase 17 (Track L.15) — echo benign control fixture.
//
// The /run route consults an allow-list before invoking exec, so
// attacker bytes never reach the sink directly.
package main
import (
"os/exec"
"github.com/labstack/echo/v4"
)
func Run(c echo.Context) error {
cmd := c.QueryParam("cmd")
allow := map[string]string{"ls": "ls", "ps": "ps"}
if safe, ok := allow[cmd]; ok {
return exec.Command(safe).Run()
}
return nil
}
func main() {
e := echo.New()
e.GET("/run", Run)
_ = e
}

View file

@ -0,0 +1,23 @@
// Phase 17 (Track L.15) — echo CMDI vuln fixture.
//
// The /run route forwards a `cmd` query parameter straight into
// `os/exec.Command`. Adapter binding: `e.GET("/run", Run)` with
// `cmd` flowing through `c.QueryParam`.
package main
import (
"os/exec"
"github.com/labstack/echo/v4"
)
func Run(c echo.Context) error {
cmd := c.QueryParam("cmd")
return exec.Command("sh", "-c", cmd).Run()
}
func main() {
e := echo.New()
e.GET("/run", Run)
_ = e
}

View file

@ -0,0 +1,23 @@
// Phase 17 (Track L.15) — fiber benign control fixture.
package main
import (
"os/exec"
"github.com/gofiber/fiber/v2"
)
func Run(c *fiber.Ctx) error {
cmd := c.Query("cmd")
allow := map[string]string{"ls": "ls", "ps": "ps"}
if safe, ok := allow[cmd]; ok {
return exec.Command(safe).Run()
}
return nil
}
func main() {
app := fiber.New()
app.Get("/run", Run)
_ = app
}

View file

@ -0,0 +1,23 @@
// Phase 17 (Track L.15) — fiber CMDI vuln fixture.
//
// The /run route forwards a `cmd` query parameter straight into
// `os/exec.Command`. Adapter binding: `app.Get("/run", Run)` with
// `cmd` flowing through `c.Query`.
package main
import (
"os/exec"
"github.com/gofiber/fiber/v2"
)
func Run(c *fiber.Ctx) error {
cmd := c.Query("cmd")
return exec.Command("sh", "-c", cmd).Run()
}
func main() {
app := fiber.New()
app.Get("/run", Run)
_ = app
}

View file

@ -0,0 +1,26 @@
// Phase 17 (Track L.15) — gin benign control fixture.
//
// The /run route accepts a `cmd` query parameter but only runs an
// allow-listed command, so the sink never sees attacker-controlled
// bytes. Same adapter binding as the vuln fixture.
package main
import (
"os/exec"
"github.com/gin-gonic/gin"
)
func Run(c *gin.Context) {
cmd := c.Query("cmd")
allow := map[string]string{"ls": "ls", "ps": "ps"}
if safe, ok := allow[cmd]; ok {
_ = exec.Command(safe).Run()
}
}
func main() {
r := gin.Default()
r.GET("/run", Run)
_ = r
}

View file

@ -0,0 +1,24 @@
// Phase 17 (Track L.15) — gin CMDI vuln fixture.
//
// The /run route forwards a `cmd` query parameter straight into
// `os/exec.Command`, so any attacker who reaches the route can
// execute arbitrary shell. Adapter binding: `r.GET("/run", Run)`
// with `cmd` flowing through `c.Query`.
package main
import (
"os/exec"
"github.com/gin-gonic/gin"
)
func Run(c *gin.Context) {
cmd := c.Query("cmd")
_ = exec.Command("sh", "-c", cmd).Run()
}
func main() {
r := gin.Default()
r.GET("/run", Run)
_ = r
}

View file

@ -0,0 +1,19 @@
//! Phase 17 (Track L.15) — actix-web benign control fixture.
use actix_web::{get, web, HttpResponse, Responder};
use serde::Deserialize;
use std::process::Command;
#[derive(Deserialize)]
pub struct RunQuery {
pub cmd: String,
}
#[get("/run")]
pub async fn run(q: web::Query<RunQuery>) -> impl Responder {
let allow = ["ls", "ps"];
if allow.contains(&q.cmd.as_str()) {
let _ = Command::new(&q.cmd).status();
}
HttpResponse::Ok().body("ok")
}

View file

@ -0,0 +1,20 @@
//! Phase 17 (Track L.15) — actix-web CMDI vuln fixture.
//!
//! The /run route forwards a `cmd` query parameter straight into
//! `std::process::Command`. Adapter binding: `#[get("/run")]` on
//! `run` with `cmd` arriving via `web::Query<RunQuery>`.
use actix_web::{get, web, HttpResponse, Responder};
use serde::Deserialize;
use std::process::Command;
#[derive(Deserialize)]
pub struct RunQuery {
pub cmd: String,
}
#[get("/run")]
pub async fn run(q: web::Query<RunQuery>) -> impl Responder {
let _ = Command::new("sh").arg("-c").arg(&q.cmd).status();
HttpResponse::Ok().body("ok")
}

View file

@ -0,0 +1,27 @@
//! Phase 17 (Track L.15) — axum benign control fixture.
//!
//! The /run route allow-lists the `cmd` value before invoking
//! `std::process::Command`, so attacker bytes never reach the sink.
use axum::extract::Query;
use axum::Router;
use axum::routing::get;
use serde::Deserialize;
use std::process::Command;
#[derive(Deserialize)]
pub struct RunQuery {
pub cmd: String,
}
pub async fn run(Query(q): Query<RunQuery>) -> String {
let allow = ["ls", "ps"];
if allow.contains(&q.cmd.as_str()) {
let _ = Command::new(&q.cmd).status();
}
"ok".to_owned()
}
pub fn build() -> Router {
Router::new().route("/run", get(run))
}

View file

@ -0,0 +1,26 @@
//! Phase 17 (Track L.15) — axum CMDI vuln fixture.
//!
//! The /run route forwards a `cmd` query parameter straight into
//! `std::process::Command`. Adapter binding:
//! `Router::new().route("/run", get(run))` with `cmd` arriving via
//! `axum::extract::Query<RunQuery>`.
use axum::extract::Query;
use axum::Router;
use axum::routing::get;
use serde::Deserialize;
use std::process::Command;
#[derive(Deserialize)]
pub struct RunQuery {
pub cmd: String,
}
pub async fn run(Query(q): Query<RunQuery>) -> String {
let _ = Command::new("sh").arg("-c").arg(&q.cmd).status();
"ok".to_owned()
}
pub fn build() -> Router {
Router::new().route("/run", get(run))
}

View file

@ -0,0 +1,13 @@
//! Phase 17 (Track L.15) — rocket benign control fixture.
use rocket::get;
use std::process::Command;
#[get("/run?<cmd>")]
pub fn run(cmd: String) -> &'static str {
let allow = ["ls", "ps"];
if allow.contains(&cmd.as_str()) {
let _ = Command::new(&cmd).status();
}
"ok"
}

View file

@ -0,0 +1,14 @@
//! Phase 17 (Track L.15) — rocket CMDI vuln fixture.
//!
//! The /run route forwards a `cmd` query parameter straight into
//! `std::process::Command`. Adapter binding: `#[get("/run?<cmd>")]`
//! on `run` with `cmd` arriving via the function's positional arg.
use rocket::get;
use std::process::Command;
#[get("/run?<cmd>")]
pub fn run(cmd: String) -> &'static str {
let _ = Command::new("sh").arg("-c").arg(&cmd).status();
"ok"
}

View file

@ -0,0 +1,24 @@
//! Phase 17 (Track L.15) — warp benign control fixture.
use std::process::Command;
use serde::Deserialize;
use warp::Filter;
#[derive(Deserialize)]
pub struct RunQuery {
pub cmd: String,
}
pub fn run(q: RunQuery) -> &'static str {
let allow = ["ls", "ps"];
if allow.contains(&q.cmd.as_str()) {
let _ = Command::new(&q.cmd).status();
}
"ok"
}
pub fn build() -> impl Filter<Extract = (&'static str,), Error = warp::Rejection> + Clone {
warp::path!("run")
.and(warp::query::<RunQuery>())
.map(run)
}

View file

@ -0,0 +1,26 @@
//! Phase 17 (Track L.15) — warp CMDI vuln fixture.
//!
//! The /run filter forwards a query parameter straight into
//! `std::process::Command`. Adapter binding:
//! `warp::path!("run").and(warp::query::<RunQuery>()).map(run)` with
//! `cmd` arriving via warp's typed query.
use std::process::Command;
use serde::Deserialize;
use warp::Filter;
#[derive(Deserialize)]
pub struct RunQuery {
pub cmd: String,
}
pub fn run(q: RunQuery) -> &'static str {
let _ = Command::new("sh").arg("-c").arg(&q.cmd).status();
"ok"
}
pub fn build() -> impl Filter<Extract = (&'static str,), Error = warp::Rejection> + Clone {
warp::path!("run")
.and(warp::query::<RunQuery>())
.map(run)
}

View file

@ -0,0 +1,130 @@
//! Phase 17 (Track L.15) — Go framework adapter integration tests.
//!
//! Each test exercises `detect_binding` end-to-end against a fixture
//! file under `tests/dynamic_fixtures/go_frameworks/`, asserting that
//! the right adapter fires, the binding carries
//! `EntryKind::HttpRoute`, and the `RouteShape` matches the brief.
//! 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};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
fn parse_go(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_go::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: "go".into(),
..Default::default()
}
}
fn assert_route(path: &str, adapter: &str, route_path: &str) {
let bytes = std::fs::read(path).expect("fixture exists");
let tree = parse_go(&bytes);
let summary = summary_for("Run", path);
let binding =
detect_binding(&summary, tree.root_node(), &bytes, Lang::Go).expect("adapter must bind");
assert_eq!(binding.adapter, adapter, "wrong adapter for {path}");
assert_eq!(binding.kind, EntryKind::HttpRoute);
let route = binding.route.as_ref().expect("route");
assert_eq!(route.path, route_path);
assert_eq!(route.method, HttpMethod::GET);
}
#[test]
fn gin_vuln_fixture_binds_route() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/gin/vuln.go",
"go-gin",
"/run",
);
}
#[test]
fn gin_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/gin/benign.go",
"go-gin",
"/run",
);
}
#[test]
fn echo_vuln_fixture_binds_route() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/echo/vuln.go",
"go-echo",
"/run",
);
}
#[test]
fn echo_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/echo/benign.go",
"go-echo",
"/run",
);
}
#[test]
fn fiber_vuln_fixture_binds_route() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/fiber/vuln.go",
"go-fiber",
"/run",
);
}
#[test]
fn fiber_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/fiber/benign.go",
"go-fiber",
"/run",
);
}
#[test]
fn chi_vuln_fixture_binds_route() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/chi/vuln.go",
"go-chi",
"/run",
);
}
#[test]
fn chi_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/go_frameworks/chi/benign.go",
"go-chi",
"/run",
);
}
#[test]
fn gin_adapter_ignores_unrelated_function() {
// Match a non-route function name to confirm the adapter does
// not over-fire on unrelated helpers in the same file.
let path = "tests/dynamic_fixtures/go_frameworks/gin/vuln.go";
let bytes = std::fs::read(path).expect("fixture exists");
let tree = parse_go(&bytes);
let summary = summary_for("NonexistentHelper", path);
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Go);
assert!(binding.is_none());
}

View file

@ -0,0 +1,140 @@
//! Phase 17 (Track L.15) — Rust framework adapter integration tests.
//!
//! Each test exercises `detect_binding` end-to-end against a fixture
//! file under `tests/dynamic_fixtures/rust_frameworks/`, asserting
//! that the right adapter fires, the binding carries
//! `EntryKind::HttpRoute`, and the `RouteShape` matches the brief.
//! 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};
use nyx_scanner::evidence::EntryKind;
use nyx_scanner::summary::FuncSummary;
use nyx_scanner::symbol::Lang;
fn parse_rust(src: &[u8]) -> tree_sitter::Tree {
let mut parser = tree_sitter::Parser::new();
let lang = tree_sitter::Language::from(tree_sitter_rust::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: "rust".into(),
..Default::default()
}
}
fn assert_route(path: &str, adapter: &str, expected_path_fragment: &str, method: HttpMethod) {
let bytes = std::fs::read(path).expect("fixture exists");
let tree = parse_rust(&bytes);
let summary = summary_for("run", path);
let binding =
detect_binding(&summary, tree.root_node(), &bytes, Lang::Rust).expect("adapter must bind");
assert_eq!(binding.adapter, adapter, "wrong adapter for {path}");
assert_eq!(binding.kind, EntryKind::HttpRoute);
let route = binding.route.as_ref().expect("route");
assert!(
route.path.contains(expected_path_fragment),
"route path {} should contain {expected_path_fragment}",
route.path
);
assert_eq!(route.method, method);
}
#[test]
fn axum_vuln_fixture_binds_route() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/axum/vuln.rs",
"rust-axum",
"/run",
HttpMethod::GET,
);
}
#[test]
fn axum_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/axum/benign.rs",
"rust-axum",
"/run",
HttpMethod::GET,
);
}
#[test]
fn actix_vuln_fixture_binds_route_via_attribute() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/actix/vuln.rs",
"rust-actix",
"/run",
HttpMethod::GET,
);
}
#[test]
fn actix_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/actix/benign.rs",
"rust-actix",
"/run",
HttpMethod::GET,
);
}
#[test]
fn rocket_vuln_fixture_binds_route_via_attribute() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/rocket/vuln.rs",
"rust-rocket",
"/run",
HttpMethod::GET,
);
}
#[test]
fn rocket_benign_fixture_binds_same_route_shape() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/rocket/benign.rs",
"rust-rocket",
"/run",
HttpMethod::GET,
);
}
#[test]
fn warp_vuln_fixture_binds_path_macro() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/warp/vuln.rs",
"rust-warp",
"run",
HttpMethod::GET,
);
}
#[test]
fn warp_benign_fixture_binds_same_path_macro() {
assert_route(
"tests/dynamic_fixtures/rust_frameworks/warp/benign.rs",
"rust-warp",
"run",
HttpMethod::GET,
);
}
#[test]
fn axum_adapter_ignores_unrelated_function() {
let path = "tests/dynamic_fixtures/rust_frameworks/axum/vuln.rs";
let bytes = std::fs::read(path).expect("fixture exists");
let tree = parse_rust(&bytes);
let summary = summary_for("nonexistent_helper", path);
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Rust);
assert!(binding.is_none());
}