From 2b96c6005bd71b916272fa4d5da9f65df6196cce Mon Sep 17 00:00:00 2001 From: pitboss Date: Wed, 20 May 2026 12:24:31 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2017:=20Track=20L.15=20?= =?UTF-8?q?=E2=80=94=20Gin=20/=20Echo=20/=20Fiber=20/=20Chi=20adapters=20+?= =?UTF-8?q?=20Axum=20/=20Actix=20/=20Rocket=20/=20Warp=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/framework/adapters/go_chi.rs | 126 +++ src/dynamic/framework/adapters/go_echo.rs | 127 +++ src/dynamic/framework/adapters/go_fiber.rs | 133 ++++ src/dynamic/framework/adapters/go_gin.rs | 152 ++++ src/dynamic/framework/adapters/go_routes.rs | 456 +++++++++++ src/dynamic/framework/adapters/mod.rs | 18 + src/dynamic/framework/adapters/rust_actix.rs | 129 ++++ src/dynamic/framework/adapters/rust_axum.rs | 132 ++++ src/dynamic/framework/adapters/rust_rocket.rs | 125 +++ src/dynamic/framework/adapters/rust_routes.rs | 728 ++++++++++++++++++ src/dynamic/framework/adapters/rust_warp.rs | 128 +++ src/dynamic/framework/mod.rs | 23 +- src/dynamic/framework/registry.rs | 8 + src/dynamic/lang/go.rs | 176 ++++- src/dynamic/lang/rust.rs | 180 ++++- .../go_frameworks/chi/benign.go | 24 + .../go_frameworks/chi/vuln.go | 25 + .../go_frameworks/echo/benign.go | 26 + .../go_frameworks/echo/vuln.go | 23 + .../go_frameworks/fiber/benign.go | 23 + .../go_frameworks/fiber/vuln.go | 23 + .../go_frameworks/gin/benign.go | 26 + .../go_frameworks/gin/vuln.go | 24 + .../rust_frameworks/actix/benign.rs | 19 + .../rust_frameworks/actix/vuln.rs | 20 + .../rust_frameworks/axum/benign.rs | 27 + .../rust_frameworks/axum/vuln.rs | 26 + .../rust_frameworks/rocket/benign.rs | 13 + .../rust_frameworks/rocket/vuln.rs | 14 + .../rust_frameworks/warp/benign.rs | 24 + .../rust_frameworks/warp/vuln.rs | 26 + tests/go_frameworks_corpus.rs | 130 ++++ tests/rust_frameworks_corpus.rs | 140 ++++ 33 files changed, 3247 insertions(+), 27 deletions(-) create mode 100644 src/dynamic/framework/adapters/go_chi.rs create mode 100644 src/dynamic/framework/adapters/go_echo.rs create mode 100644 src/dynamic/framework/adapters/go_fiber.rs create mode 100644 src/dynamic/framework/adapters/go_gin.rs create mode 100644 src/dynamic/framework/adapters/go_routes.rs create mode 100644 src/dynamic/framework/adapters/rust_actix.rs create mode 100644 src/dynamic/framework/adapters/rust_axum.rs create mode 100644 src/dynamic/framework/adapters/rust_rocket.rs create mode 100644 src/dynamic/framework/adapters/rust_routes.rs create mode 100644 src/dynamic/framework/adapters/rust_warp.rs create mode 100644 tests/dynamic_fixtures/go_frameworks/chi/benign.go create mode 100644 tests/dynamic_fixtures/go_frameworks/chi/vuln.go create mode 100644 tests/dynamic_fixtures/go_frameworks/echo/benign.go create mode 100644 tests/dynamic_fixtures/go_frameworks/echo/vuln.go create mode 100644 tests/dynamic_fixtures/go_frameworks/fiber/benign.go create mode 100644 tests/dynamic_fixtures/go_frameworks/fiber/vuln.go create mode 100644 tests/dynamic_fixtures/go_frameworks/gin/benign.go create mode 100644 tests/dynamic_fixtures/go_frameworks/gin/vuln.go create mode 100644 tests/dynamic_fixtures/rust_frameworks/actix/benign.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/actix/vuln.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/axum/benign.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/axum/vuln.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/rocket/benign.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/rocket/vuln.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/warp/benign.rs create mode 100644 tests/dynamic_fixtures/rust_frameworks/warp/vuln.rs create mode 100644 tests/go_frameworks_corpus.rs create mode 100644 tests/rust_frameworks_corpus.rs diff --git a/src/dynamic/framework/adapters/go_chi.rs b/src/dynamic/framework/adapters/go_chi.rs new file mode 100644 index 00000000..85cc43bb --- /dev/null +++ b/src/dynamic/framework/adapters/go_chi.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/go_echo.rs b/src/dynamic/framework/adapters/go_echo.rs new file mode 100644 index 00000000..55db4023 --- /dev/null +++ b/src/dynamic/framework/adapters/go_echo.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/go_fiber.rs b/src/dynamic/framework/adapters/go_fiber.rs new file mode 100644 index 00000000..2a114d29 --- /dev/null +++ b/src/dynamic/framework/adapters/go_fiber.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/go_gin.rs b/src/dynamic/framework/adapters/go_gin.rs new file mode 100644 index 00000000..7114c2b1 --- /dev/null +++ b/src/dynamic/framework/adapters/go_gin.rs @@ -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 { + 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"); + } +} diff --git a/src/dynamic/framework/adapters/go_routes.rs b/src/dynamic/framework/adapters/go_routes.rs new file mode 100644 index 00000000..dc6f6c7d --- /dev/null +++ b/src/dynamic/framework/adapters/go_routes.rs @@ -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> { + let mut hit: Option> = None; + walk_go(root, bytes, target, &mut hit); + hit +} + +fn walk_go<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + out: &mut Option>, +) { + 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 { + let mut out: Vec = 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 { + let mut out: Vec = 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 { + 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 { + 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 +/// `.(, )`. 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> = { + 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 { + 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"]); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 64f0e911..8c1e6e01 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -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; diff --git a/src/dynamic/framework/adapters/rust_actix.rs b/src/dynamic/framework/adapters/rust_actix.rs new file mode 100644 index 00000000..cf6a6aa9 --- /dev/null +++ b/src/dynamic/framework/adapters/rust_actix.rs @@ -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) -> 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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/rust_axum.rs b/src/dynamic/framework/adapters/rust_axum.rs new file mode 100644 index 00000000..23f95a02 --- /dev/null +++ b/src/dynamic/framework/adapters/rust_axum.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/adapters/rust_rocket.rs b/src/dynamic/framework/adapters/rust_rocket.rs new file mode 100644 index 00000000..b33be781 --- /dev/null +++ b/src/dynamic/framework/adapters/rust_rocket.rs @@ -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/")] +//! fn show(id: String) -> String { id } +//! +//! #[launch] +//! fn rocket() -> _ { rocket::build().mount("/", routes![show]) } +//! ``` +//! +//! Rocket's placeholder syntax `` plus brace syntax `` +//! 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 { + 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/\")]\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/"); + 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 = \"\")]\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()); + } +} diff --git a/src/dynamic/framework/adapters/rust_routes.rs b/src/dynamic/framework/adapters/rust_routes.rs new file mode 100644 index 00000000..9165d02e --- /dev/null +++ b/src/dynamic/framework/adapters/rust_routes.rs @@ -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 ``. +//! - 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> { + let mut hit: Option> = None; + walk_rs(root, bytes, target, &mut hit); + hit +} + +fn walk_rs<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + out: &mut Option>, +) { + 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 { + let mut out: Vec = 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) { + 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 `` syntax: `/u/` → `id` +/// - typed rocket `` syntax: `/u/` → `id` +pub fn extract_rust_path_placeholders(path: &str) -> Vec { + let mut out: Vec = 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 { + 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 { + 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 { + 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> = 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 + // ` `. 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> = 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 = ""` 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 { + 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> = { + 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 { + // 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/"), vec!["id"]); + assert_eq!(extract_rust_path_placeholders("/u/"), 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 = \"\")]\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")); + } +} diff --git a/src/dynamic/framework/adapters/rust_warp.rs b/src/dynamic/framework/adapters/rust_warp.rs new file mode 100644 index 00000000..637066bb --- /dev/null +++ b/src/dynamic/framework/adapters/rust_warp.rs @@ -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 { + 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()); + } +} diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 1878a73c..0fe7a7f4 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -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); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index ad4025dd..ed41c1b2 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -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, diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 33678521..6887a03d 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -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() }"; diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index 85c0872c..4fb53b3f 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -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 { // 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 (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) {}"; @@ -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 diff --git a/tests/dynamic_fixtures/go_frameworks/chi/benign.go b/tests/dynamic_fixtures/go_frameworks/chi/benign.go new file mode 100644 index 00000000..b858ba11 --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/chi/benign.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/chi/vuln.go b/tests/dynamic_fixtures/go_frameworks/chi/vuln.go new file mode 100644 index 00000000..8f789673 --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/chi/vuln.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/echo/benign.go b/tests/dynamic_fixtures/go_frameworks/echo/benign.go new file mode 100644 index 00000000..c91f062a --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/echo/benign.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/echo/vuln.go b/tests/dynamic_fixtures/go_frameworks/echo/vuln.go new file mode 100644 index 00000000..2c466d0c --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/echo/vuln.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/fiber/benign.go b/tests/dynamic_fixtures/go_frameworks/fiber/benign.go new file mode 100644 index 00000000..17a1ea7e --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/fiber/benign.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/fiber/vuln.go b/tests/dynamic_fixtures/go_frameworks/fiber/vuln.go new file mode 100644 index 00000000..8e29e964 --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/fiber/vuln.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/gin/benign.go b/tests/dynamic_fixtures/go_frameworks/gin/benign.go new file mode 100644 index 00000000..4b035764 --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/gin/benign.go @@ -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 +} diff --git a/tests/dynamic_fixtures/go_frameworks/gin/vuln.go b/tests/dynamic_fixtures/go_frameworks/gin/vuln.go new file mode 100644 index 00000000..0a4a3c09 --- /dev/null +++ b/tests/dynamic_fixtures/go_frameworks/gin/vuln.go @@ -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 +} diff --git a/tests/dynamic_fixtures/rust_frameworks/actix/benign.rs b/tests/dynamic_fixtures/rust_frameworks/actix/benign.rs new file mode 100644 index 00000000..0897c438 --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/actix/benign.rs @@ -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) -> impl Responder { + let allow = ["ls", "ps"]; + if allow.contains(&q.cmd.as_str()) { + let _ = Command::new(&q.cmd).status(); + } + HttpResponse::Ok().body("ok") +} diff --git a/tests/dynamic_fixtures/rust_frameworks/actix/vuln.rs b/tests/dynamic_fixtures/rust_frameworks/actix/vuln.rs new file mode 100644 index 00000000..cbb947ae --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/actix/vuln.rs @@ -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`. + +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) -> impl Responder { + let _ = Command::new("sh").arg("-c").arg(&q.cmd).status(); + HttpResponse::Ok().body("ok") +} diff --git a/tests/dynamic_fixtures/rust_frameworks/axum/benign.rs b/tests/dynamic_fixtures/rust_frameworks/axum/benign.rs new file mode 100644 index 00000000..9efb0347 --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/axum/benign.rs @@ -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) -> 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)) +} diff --git a/tests/dynamic_fixtures/rust_frameworks/axum/vuln.rs b/tests/dynamic_fixtures/rust_frameworks/axum/vuln.rs new file mode 100644 index 00000000..d88b275b --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/axum/vuln.rs @@ -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`. + +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) -> String { + let _ = Command::new("sh").arg("-c").arg(&q.cmd).status(); + "ok".to_owned() +} + +pub fn build() -> Router { + Router::new().route("/run", get(run)) +} diff --git a/tests/dynamic_fixtures/rust_frameworks/rocket/benign.rs b/tests/dynamic_fixtures/rust_frameworks/rocket/benign.rs new file mode 100644 index 00000000..09d2e719 --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/rocket/benign.rs @@ -0,0 +1,13 @@ +//! Phase 17 (Track L.15) — rocket benign control fixture. + +use rocket::get; +use std::process::Command; + +#[get("/run?")] +pub fn run(cmd: String) -> &'static str { + let allow = ["ls", "ps"]; + if allow.contains(&cmd.as_str()) { + let _ = Command::new(&cmd).status(); + } + "ok" +} diff --git a/tests/dynamic_fixtures/rust_frameworks/rocket/vuln.rs b/tests/dynamic_fixtures/rust_frameworks/rocket/vuln.rs new file mode 100644 index 00000000..7e22ea44 --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/rocket/vuln.rs @@ -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?")]` +//! on `run` with `cmd` arriving via the function's positional arg. + +use rocket::get; +use std::process::Command; + +#[get("/run?")] +pub fn run(cmd: String) -> &'static str { + let _ = Command::new("sh").arg("-c").arg(&cmd).status(); + "ok" +} diff --git a/tests/dynamic_fixtures/rust_frameworks/warp/benign.rs b/tests/dynamic_fixtures/rust_frameworks/warp/benign.rs new file mode 100644 index 00000000..b16f8051 --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/warp/benign.rs @@ -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 + Clone { + warp::path!("run") + .and(warp::query::()) + .map(run) +} diff --git a/tests/dynamic_fixtures/rust_frameworks/warp/vuln.rs b/tests/dynamic_fixtures/rust_frameworks/warp/vuln.rs new file mode 100644 index 00000000..626a29ea --- /dev/null +++ b/tests/dynamic_fixtures/rust_frameworks/warp/vuln.rs @@ -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::()).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 + Clone { + warp::path!("run") + .and(warp::query::()) + .map(run) +} diff --git a/tests/go_frameworks_corpus.rs b/tests/go_frameworks_corpus.rs new file mode 100644 index 00000000..cd1f905b --- /dev/null +++ b/tests/go_frameworks_corpus.rs @@ -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()); +} diff --git a/tests/rust_frameworks_corpus.rs b/tests/rust_frameworks_corpus.rs new file mode 100644 index 00000000..d6eab037 --- /dev/null +++ b/tests/rust_frameworks_corpus.rs @@ -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()); +}