mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 16: Track L.14 — Laravel / Symfony / CodeIgniter adapters
This commit is contained in:
parent
323abca489
commit
7ddb7b90e5
18 changed files with 1722 additions and 20 deletions
|
|
@ -34,6 +34,10 @@ pub mod js_routes;
|
|||
pub mod ldap_php;
|
||||
pub mod ldap_python;
|
||||
pub mod ldap_spring;
|
||||
pub mod php_codeigniter;
|
||||
pub mod php_laravel;
|
||||
pub mod php_routes;
|
||||
pub mod php_symfony;
|
||||
pub mod php_twig;
|
||||
pub mod php_unserialize;
|
||||
pub mod pp_json_deep_assign;
|
||||
|
|
@ -90,6 +94,9 @@ pub use js_nest::{JsNestAdapter, TsNestAdapter};
|
|||
pub use ldap_php::LdapPhpAdapter;
|
||||
pub use ldap_python::LdapPythonAdapter;
|
||||
pub use ldap_spring::LdapSpringAdapter;
|
||||
pub use php_codeigniter::PhpCodeIgniterAdapter;
|
||||
pub use php_laravel::PhpLaravelAdapter;
|
||||
pub use php_symfony::PhpSymfonyAdapter;
|
||||
pub use php_twig::PhpTwigAdapter;
|
||||
pub use php_unserialize::PhpUnserializeAdapter;
|
||||
pub use pp_json_deep_assign::{PpJsonDeepAssignJsAdapter, PpJsonDeepAssignTsAdapter};
|
||||
|
|
|
|||
136
src/dynamic/framework/adapters/php_codeigniter.rs
Normal file
136
src/dynamic/framework/adapters/php_codeigniter.rs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
//! CodeIgniter [`super::super::FrameworkAdapter`] (Phase 16 — Track L.14).
|
||||
//!
|
||||
//! Recognises `$routes->get('users/(:num)', 'UserController::show')` /
|
||||
//! `$routes->post(...)` route declarations declared inside the
|
||||
//! conventional `app/Config/Routes.php` plus the matching controller
|
||||
//! method declared inside an `extends BaseController` class.
|
||||
//!
|
||||
//! CodeIgniter 4's placeholder vocabulary covers `(:num)`,
|
||||
//! `(:alpha)`, `(:alphanum)`, `(:any)`, `(:segment)`, `(:hash)` —
|
||||
//! [`super::php_routes::extract_php_path_placeholders`] returns the
|
||||
//! inner name (after the `:`) for each so a `$id` formal whose name
|
||||
//! matches the placeholder binds as [`super::super::ParamSource::PathSegment`].
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||
#[cfg(test)]
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::php_routes::{
|
||||
bind_php_path_params, find_codeigniter_route, find_php_function, php_class_name,
|
||||
php_formal_names, source_imports_codeigniter,
|
||||
};
|
||||
|
||||
pub struct PhpCodeIgniterAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "php-codeigniter";
|
||||
|
||||
impl FrameworkAdapter for PhpCodeIgniterAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Php
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_codeigniter(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?;
|
||||
let controller = class.and_then(|c| php_class_name(c, file_bytes));
|
||||
|
||||
let (method, path) =
|
||||
find_codeigniter_route(ast, file_bytes, &summary.name, controller)?;
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_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::ParamSource;
|
||||
|
||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: "php".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_get_route_with_double_colon_callable() {
|
||||
let src: &[u8] = b"<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->get('users/(:num)', 'UserController::show');\nclass UserController extends BaseController {\n public function show($num) { return $num; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpCodeIgniterAdapter
|
||||
.detect(&summary("show"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "php-codeigniter");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.expect("route");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "users/(:num)");
|
||||
let num = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "num")
|
||||
.unwrap();
|
||||
assert!(matches!(num.source, ParamSource::PathSegment(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_post_with_closure_callable() {
|
||||
let src: &[u8] = b"<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->post('save', function ($payload) { return $payload; });\nfunction save($payload) { return $payload; }\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpCodeIgniterAdapter
|
||||
.detect(&summary("save"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.unwrap().method, HttpMethod::POST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_codeigniter_not_imported() {
|
||||
let src: &[u8] = b"<?php\n$routes->get('users/(:num)', 'UserController::show');\n";
|
||||
let tree = parse(src);
|
||||
assert!(PhpCodeIgniterAdapter
|
||||
.detect(&summary("show"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_callable_does_not_reference_method() {
|
||||
let src: &[u8] = b"<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->get('users/(:num)', 'UserController::show');\nclass UserController extends BaseController {\n public function helper($x) { return $x; }\n}\n";
|
||||
let tree = parse(src);
|
||||
assert!(PhpCodeIgniterAdapter
|
||||
.detect(&summary("helper"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
159
src/dynamic/framework/adapters/php_laravel.rs
Normal file
159
src/dynamic/framework/adapters/php_laravel.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! Laravel [`super::super::FrameworkAdapter`] (Phase 16 — Track L.14).
|
||||
//!
|
||||
//! Two recognition shapes:
|
||||
//!
|
||||
//! - Closure route: `Route::get('/path', function ($payload) {…})`
|
||||
//! declared at top level — the closure's function name is the
|
||||
//! enclosing summary's name (the static-analysis side already
|
||||
//! stamps anonymous closures with a synthetic name slot).
|
||||
//! - Controller-method route:
|
||||
//! `Route::get('/path', 'UserController@show')` /
|
||||
//! `Route::post('/path', [UserController::class, 'save'])` plus
|
||||
//! a `class UserController { public function show($id) {…} }`
|
||||
//! declaration in the same file.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||
#[cfg(test)]
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::php_routes::{
|
||||
bind_php_path_params, find_laravel_static_route, find_php_function, php_class_name,
|
||||
php_formal_names, source_imports_laravel,
|
||||
};
|
||||
|
||||
pub struct PhpLaravelAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "php-laravel";
|
||||
|
||||
impl FrameworkAdapter for PhpLaravelAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Php
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_laravel(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?;
|
||||
let controller = class.and_then(|c| php_class_name(c, file_bytes));
|
||||
|
||||
let (method, path) =
|
||||
find_laravel_static_route(ast, file_bytes, &summary.name, controller)?;
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_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::ParamSource;
|
||||
|
||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: "php".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_route_get_with_controller_method() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users/{id}', 'UserController@show');\nclass UserController {\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect(&summary("show"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "php-laravel");
|
||||
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"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::post('/save', function ($payload) { return $payload; });\nfunction save($payload) { return $payload; }\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect(&summary("save"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(route.path, "/save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_array_callable() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::put('/users/{id}', [UserController::class, 'update']);\nclass UserController {\n public function update($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect(&summary("update"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.unwrap().method, HttpMethod::PUT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_double_colon_callable() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::delete('/users/{id}', 'UserController::destroy');\nclass UserController {\n public function destroy($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect(&summary("destroy"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_laravel_not_imported() {
|
||||
let src: &[u8] = b"<?php\nfunction f($x) { return $x; }\n";
|
||||
let tree = parse(src);
|
||||
assert!(PhpLaravelAdapter
|
||||
.detect(&summary("f"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_route_mapping_does_not_reference_function() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users', 'UserController@show');\nfunction helper($x) { return $x; }\n";
|
||||
let tree = parse(src);
|
||||
assert!(PhpLaravelAdapter
|
||||
.detect(&summary("helper"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
817
src/dynamic/framework/adapters/php_routes.rs
Normal file
817
src/dynamic/framework/adapters/php_routes.rs
Normal file
|
|
@ -0,0 +1,817 @@
|
|||
//! Shared PHP-route adapter helpers (Phase 16 — Track L.14).
|
||||
//!
|
||||
//! The Laravel / Symfony / CodeIgniter adapters all need the same
|
||||
//! handful of tree-sitter helpers: locate a `function_definition` or
|
||||
//! `method_declaration` by name, enumerate formal parameter names,
|
||||
//! walk a method-level or class-level `attribute_list`
|
||||
//! (`#[Route(...)]`), parse `Route::get('/x', ...)` static calls and
|
||||
//! `$routes->get('users/(:num)', 'Controller::method')` member
|
||||
//! calls, and bind formals to request slots. Centralising the
|
||||
//! helpers here keeps the three adapters terse and lets every
|
||||
//! framework share the same placeholder-binding semantics.
|
||||
|
||||
use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource};
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// True when `bytes` carries any of the well-known Laravel import
|
||||
/// stanzas (the `Route::` facade, `Illuminate\…` namespace, the
|
||||
/// `Illuminate\Routing\Router` class, the convention-based
|
||||
/// `app/Http/Controllers` base class, or a `# nyx-shape: laravel`
|
||||
/// annotation).
|
||||
pub fn source_imports_laravel(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"Illuminate\\Routing",
|
||||
b"Illuminate\\Http",
|
||||
b"Illuminate\\Support\\Facades\\Route",
|
||||
b"use Illuminate\\",
|
||||
b"Route::get(",
|
||||
b"Route::post(",
|
||||
b"Route::put(",
|
||||
b"Route::patch(",
|
||||
b"Route::delete(",
|
||||
b"Route::any(",
|
||||
b"Route::match(",
|
||||
b"App\\Http\\Controllers",
|
||||
b"// nyx-shape: laravel",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `bytes` carries any of the well-known Symfony import
|
||||
/// stanzas (the `Symfony\…` namespace, the `#[Route]` attribute, the
|
||||
/// `AbstractController` base class).
|
||||
pub fn source_imports_symfony(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"Symfony\\Component\\Routing",
|
||||
b"Symfony\\Component\\HttpFoundation",
|
||||
b"Symfony\\Bundle\\FrameworkBundle",
|
||||
b"use Symfony\\",
|
||||
b"Symfony\\Component\\Routing\\Annotation\\Route",
|
||||
b"Symfony\\Component\\Routing\\Attribute\\Route",
|
||||
b"AbstractController",
|
||||
b"// nyx-shape: symfony",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
/// True when `bytes` carries any of the well-known CodeIgniter
|
||||
/// import stanzas (the `CodeIgniter\…` namespace, the `$routes`
|
||||
/// service used inside `app/Config/Routes.php`, the convention-based
|
||||
/// `extends BaseController`, or a `# nyx-shape: codeigniter`
|
||||
/// annotation).
|
||||
pub fn source_imports_codeigniter(bytes: &[u8]) -> bool {
|
||||
contains_any(
|
||||
bytes,
|
||||
&[
|
||||
b"CodeIgniter\\Router",
|
||||
b"CodeIgniter\\HTTP",
|
||||
b"CodeIgniter\\Controller",
|
||||
b"use CodeIgniter\\",
|
||||
b"$routes->get(",
|
||||
b"$routes->post(",
|
||||
b"$routes->put(",
|
||||
b"$routes->patch(",
|
||||
b"$routes->delete(",
|
||||
b"$routes->add(",
|
||||
b"extends BaseController",
|
||||
b"// nyx-shape: codeigniter",
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
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_definition` or a `method_declaration`
|
||||
/// whose `name` field equals `target`. Returns
|
||||
/// `(node, enclosing_class_decl)` — the class is `Some` when the
|
||||
/// match is a method.
|
||||
pub fn find_php_function<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
) -> Option<(Node<'a>, Option<Node<'a>>)> {
|
||||
let mut hit: Option<(Node<'a>, Option<Node<'a>>)> = None;
|
||||
walk(root, bytes, target, None, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn walk<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
enclosing_class: Option<Node<'a>>,
|
||||
out: &mut Option<(Node<'a>, Option<Node<'a>>)>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
let here_class = if node.kind() == "class_declaration" {
|
||||
Some(node)
|
||||
} else {
|
||||
enclosing_class
|
||||
};
|
||||
if matches!(node.kind(), "function_definition" | "method_declaration")
|
||||
&& let Some(name) = node
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
{
|
||||
if name == target {
|
||||
let klass = if node.kind() == "method_declaration" {
|
||||
here_class
|
||||
} else {
|
||||
None
|
||||
};
|
||||
*out = Some((node, klass));
|
||||
return;
|
||||
}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk(child, bytes, target, here_class, out);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enumerate formal parameter names from a `function_definition` /
|
||||
/// `method_declaration` node. Strips the leading `$` sigil from each
|
||||
/// `variable_name` so `$id` → `id`.
|
||||
pub fn php_formal_names(func: Node<'_>, bytes: &[u8]) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let Some(parameters) = func.child_by_field_name("parameters") else {
|
||||
return out;
|
||||
};
|
||||
let mut cur = parameters.walk();
|
||||
for fp in parameters.named_children(&mut cur) {
|
||||
if fp.kind() != "simple_parameter" && fp.kind() != "variadic_parameter" {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = fp.child_by_field_name("name") else {
|
||||
continue;
|
||||
};
|
||||
let Ok(text) = name.utf8_text(bytes) else {
|
||||
continue;
|
||||
};
|
||||
let trimmed = text.trim_start_matches('$').to_owned();
|
||||
if !trimmed.is_empty() {
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Read the simple class name from a `class_declaration` node — its
|
||||
/// `name` field, which is a `name` leaf node.
|
||||
pub fn php_class_name<'a>(class: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
class
|
||||
.child_by_field_name("name")
|
||||
.and_then(|n| n.utf8_text(bytes).ok())
|
||||
}
|
||||
|
||||
/// Walk the `attribute_list` attached to a `class_declaration`,
|
||||
/// `method_declaration`, or `function_definition` and invoke `visit`
|
||||
/// for each contained `attribute`. The visitor receives the
|
||||
/// `attribute` node + the attribute's leaf name (the last segment of
|
||||
/// the qualified name — `Symfony\…\Route` → `"Route"`).
|
||||
pub fn iter_php_attributes<'a, F>(node: Node<'a>, bytes: &'a [u8], mut visit: F)
|
||||
where
|
||||
F: FnMut(Node<'a>, &str),
|
||||
{
|
||||
let Some(attrs) = node.child_by_field_name("attributes") else {
|
||||
return;
|
||||
};
|
||||
let mut gc = attrs.walk();
|
||||
for group in attrs.named_children(&mut gc) {
|
||||
if group.kind() != "attribute_group" {
|
||||
continue;
|
||||
}
|
||||
let mut ac = group.walk();
|
||||
for ann in group.named_children(&mut ac) {
|
||||
if ann.kind() != "attribute" {
|
||||
continue;
|
||||
}
|
||||
if let Some(leaf) = attribute_leaf_name(ann, bytes) {
|
||||
visit(ann, leaf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn attribute_leaf_name<'a>(ann: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> {
|
||||
let mut cur = ann.walk();
|
||||
for child in ann.named_children(&mut cur) {
|
||||
if matches!(child.kind(), "name" | "qualified_name" | "relative_name") {
|
||||
let text = child.utf8_text(bytes).ok()?;
|
||||
return Some(text.rsplit('\\').next().unwrap_or(text));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// First positional string-argument from an `attribute` /
|
||||
/// `function_call_expression` / `member_call_expression` /
|
||||
/// `scoped_call_expression` arguments node.
|
||||
pub fn first_php_string_arg(arguments: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
let mut cur = arguments.walk();
|
||||
for arg in arguments.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
if arg.child_by_field_name("name").is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = arg.named_child(0)
|
||||
&& let Some(s) = string_content(value, bytes)
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Read a named-argument's string value (e.g. `path: "/x"` →
|
||||
/// `Some("/x")`).
|
||||
pub fn named_string_arg(arguments: Node<'_>, bytes: &[u8], key: &str) -> Option<String> {
|
||||
let mut cur = arguments.walk();
|
||||
for arg in arguments.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
let Some(name_node) = arg.child_by_field_name("name") else {
|
||||
continue;
|
||||
};
|
||||
if name_node.utf8_text(bytes).ok() != Some(key) {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = named_arg_value(arg, name_node)
|
||||
&& let Some(s) = string_content(value, bytes)
|
||||
{
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Parse a Symfony-style `methods: ['POST', 'PUT']` named argument
|
||||
/// from an `arguments` node and return the first method, or `None`
|
||||
/// when the kwarg is missing.
|
||||
pub fn methods_named_arg(arguments: Node<'_>, bytes: &[u8]) -> Option<HttpMethod> {
|
||||
let mut cur = arguments.walk();
|
||||
for arg in arguments.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
let Some(name_node) = arg.child_by_field_name("name") else {
|
||||
continue;
|
||||
};
|
||||
if name_node.utf8_text(bytes).ok() != Some("methods") {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = named_arg_value(arg, name_node) else {
|
||||
continue;
|
||||
};
|
||||
let raw = value.utf8_text(bytes).ok()?;
|
||||
for verb in ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"] {
|
||||
if raw.contains(verb) {
|
||||
return HttpMethod::from_ident(verb);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Inside a named `argument` node (one with a `name` field), pick the
|
||||
/// value child — the first named child whose byte range does not
|
||||
/// coincide with the `name` field's range. Tree-sitter PHP exposes
|
||||
/// both the field-name leaf and the value as named children, so
|
||||
/// `arg.named_child(0)` would otherwise return the leaf.
|
||||
fn named_arg_value<'a>(arg: Node<'a>, name_node: Node<'a>) -> Option<Node<'a>> {
|
||||
let name_range = name_node.byte_range();
|
||||
let mut cur = arg.walk();
|
||||
arg.named_children(&mut cur)
|
||||
.find(|c| c.byte_range() != name_range)
|
||||
}
|
||||
|
||||
/// Read the raw string content of a `string` / `encapsed_string` /
|
||||
/// `name` value node, stripping the surrounding quotes (single,
|
||||
/// double, or backtick).
|
||||
pub fn string_content(node: Node<'_>, bytes: &[u8]) -> Option<String> {
|
||||
let raw = node.utf8_text(bytes).ok()?;
|
||||
let trimmed = raw.trim();
|
||||
let stripped = trimmed
|
||||
.trim_matches('\'')
|
||||
.trim_matches('"')
|
||||
.trim_matches('`');
|
||||
if stripped == trimmed {
|
||||
return None;
|
||||
}
|
||||
Some(stripped.to_owned())
|
||||
}
|
||||
|
||||
/// Parse a Laravel/Symfony brace placeholder syntax (`/users/{id}` →
|
||||
/// `id`; `/u/{id?}` → `id`) and a CodeIgniter parenthesised
|
||||
/// placeholder syntax (`users/(:num)`, `users/(:any)`,
|
||||
/// `users/(:segment)`). Brace placeholders win when both are
|
||||
/// present.
|
||||
pub fn extract_php_path_placeholders(path: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |name: String| {
|
||||
if !name.is_empty() && !out.iter().any(|n| n == &name) {
|
||||
out.push(name);
|
||||
}
|
||||
};
|
||||
let bytes = path.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
match bytes[i] {
|
||||
b'{' => {
|
||||
if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') {
|
||||
let inner = &path[i + 1..i + 1 + end];
|
||||
let stripped = inner.trim_end_matches('?');
|
||||
let name = stripped.split(':').next().unwrap_or(stripped).trim();
|
||||
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];
|
||||
if let Some(name) = inner.strip_prefix(':') {
|
||||
push(name.trim().to_owned());
|
||||
}
|
||||
i += end + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bind formals to request slots given a route path template.
|
||||
///
|
||||
/// A formal whose name matches a placeholder becomes a
|
||||
/// [`ParamSource::PathSegment`]. `request` / `req` / `response` /
|
||||
/// `res` go to [`ParamSource::Implicit`] (the Laravel
|
||||
/// `IlluminateRequest`, Symfony `Request`, CodeIgniter
|
||||
/// `IncomingRequest`). Every other formal falls back to a
|
||||
/// [`ParamSource::QueryParam`] of the same name.
|
||||
pub fn bind_php_path_params(formals: &[String], path: &str) -> Vec<ParamBinding> {
|
||||
let placeholders = extract_php_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, "request" | "req" | "response" | "res")
|
||||
}
|
||||
|
||||
/// Walk every `scoped_call_expression` in the file looking for a
|
||||
/// `Route::get('/path', ...)` / `Route::post(...)` mapping that
|
||||
/// references `target` either as a string callable (`'Controller@method'`,
|
||||
/// `'Controller::method'`, `[Controller::class, 'method']`) or as a
|
||||
/// closure declared inline (matched by callable arg-position only —
|
||||
/// the adapter then accepts the binding because the surrounding
|
||||
/// adapter has already matched the function's name to a Laravel route
|
||||
/// shape). Returns `(method, path)` on first match.
|
||||
pub fn find_laravel_static_route<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut hit: Option<(HttpMethod, String)> = None;
|
||||
visit_laravel_routes(root, bytes, target, controller, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn visit_laravel_routes<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
out: &mut Option<(HttpMethod, String)>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "scoped_call_expression"
|
||||
&& let Some(found) = try_laravel_route(node, bytes, target, controller)
|
||||
{
|
||||
*out = Some(found);
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
visit_laravel_routes(child, bytes, target, controller, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_laravel_route<'a>(
|
||||
call: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let scope = call.child_by_field_name("scope")?.utf8_text(bytes).ok()?;
|
||||
let scope_leaf = scope.rsplit('\\').next().unwrap_or(scope);
|
||||
if scope_leaf != "Route" {
|
||||
return None;
|
||||
}
|
||||
let verb_node = call.child_by_field_name("name")?.utf8_text(bytes).ok()?;
|
||||
let method = verb_method(verb_node)?;
|
||||
let args = call.child_by_field_name("arguments")?;
|
||||
let path = first_php_string_arg(args, bytes)?;
|
||||
if !laravel_callable_matches(args, bytes, target, controller) {
|
||||
return None;
|
||||
}
|
||||
Some((method, path))
|
||||
}
|
||||
|
||||
/// Check the second positional arg of a `Route::verb('/x', ...)` call
|
||||
/// against `target` (the action method name). Accepts:
|
||||
/// - Closures (treated as a wildcard — surrounding adapter has
|
||||
/// already matched the function name)
|
||||
/// - `'Controller@method'` / `'Controller::method'` strings
|
||||
/// - `[ Controller::class, 'method' ]` arrays
|
||||
fn laravel_callable_matches(
|
||||
arguments: Node<'_>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> bool {
|
||||
let mut cur = arguments.walk();
|
||||
let mut positional: Vec<Node<'_>> = Vec::new();
|
||||
for arg in arguments.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
if arg.child_by_field_name("name").is_some() {
|
||||
continue;
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
let Some(callable_arg) = positional.get(1) else {
|
||||
return false;
|
||||
};
|
||||
let Some(value) = callable_arg.named_child(0) else {
|
||||
return false;
|
||||
};
|
||||
match value.kind() {
|
||||
"anonymous_function" | "anonymous_function_creation_expression" | "arrow_function" => true,
|
||||
"string" | "encapsed_string" => {
|
||||
let Some(literal) = string_content(value, bytes) else {
|
||||
return false;
|
||||
};
|
||||
let (ctrl, act) = split_laravel_callable(&literal);
|
||||
if act != target {
|
||||
return false;
|
||||
}
|
||||
match controller {
|
||||
Some(c) => ctrl.as_deref() == Some(c),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
"array_creation_expression" => {
|
||||
let Some((ctrl, action)) = parse_array_callable(value, bytes) else {
|
||||
return false;
|
||||
};
|
||||
if action != target {
|
||||
return false;
|
||||
}
|
||||
match controller {
|
||||
Some(c) => ctrl.as_deref() == Some(c),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_array_callable<'a>(
|
||||
array: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
) -> Option<(Option<String>, String)> {
|
||||
let mut cur = array.walk();
|
||||
let elements: Vec<Node<'a>> = array
|
||||
.named_children(&mut cur)
|
||||
.filter(|c| c.kind() == "array_element_initializer")
|
||||
.collect();
|
||||
if elements.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let action_value = elements[1].named_child(0)?;
|
||||
let action = string_content(action_value, bytes)?;
|
||||
let ctrl_text = elements[0].utf8_text(bytes).ok()?.trim();
|
||||
let ctrl = ctrl_text
|
||||
.strip_suffix("::class")
|
||||
.map(|s| leaf(s).to_owned());
|
||||
Some((ctrl, action))
|
||||
}
|
||||
|
||||
fn split_laravel_callable(literal: &str) -> (Option<String>, String) {
|
||||
if let Some((ctrl, act)) = literal.split_once('@') {
|
||||
return (Some(leaf(ctrl).to_owned()), act.to_owned());
|
||||
}
|
||||
if let Some((ctrl, act)) = literal.rsplit_once("::") {
|
||||
return (Some(leaf(ctrl).to_owned()), act.to_owned());
|
||||
}
|
||||
(None, literal.to_owned())
|
||||
}
|
||||
|
||||
fn leaf(qualified: &str) -> &str {
|
||||
let last_backslash = qualified.rsplit('\\').next().unwrap_or(qualified);
|
||||
last_backslash
|
||||
.rsplit("::")
|
||||
.next()
|
||||
.unwrap_or(last_backslash)
|
||||
}
|
||||
|
||||
fn verb_method(verb: &str) -> Option<HttpMethod> {
|
||||
match verb {
|
||||
"get" => Some(HttpMethod::GET),
|
||||
"post" => Some(HttpMethod::POST),
|
||||
"put" => Some(HttpMethod::PUT),
|
||||
"patch" => Some(HttpMethod::PATCH),
|
||||
"delete" => Some(HttpMethod::DELETE),
|
||||
"options" => Some(HttpMethod::OPTIONS),
|
||||
"head" => Some(HttpMethod::HEAD),
|
||||
"any" | "match" => Some(HttpMethod::GET),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk every `member_call_expression` in the file looking for a
|
||||
/// CodeIgniter `$routes->get('users/(:num)', 'Controller::method')`
|
||||
/// mapping that references `target` as the callable argument.
|
||||
/// Returns `(method, path)` on first match.
|
||||
pub fn find_codeigniter_route<'a>(
|
||||
root: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let mut hit: Option<(HttpMethod, String)> = None;
|
||||
visit_codeigniter_routes(root, bytes, target, controller, &mut hit);
|
||||
hit
|
||||
}
|
||||
|
||||
fn visit_codeigniter_routes<'a>(
|
||||
node: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
out: &mut Option<(HttpMethod, String)>,
|
||||
) {
|
||||
if out.is_some() {
|
||||
return;
|
||||
}
|
||||
if node.kind() == "member_call_expression"
|
||||
&& let Some(found) = try_codeigniter_route(node, bytes, target, controller)
|
||||
{
|
||||
*out = Some(found);
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
visit_codeigniter_routes(child, bytes, target, controller, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_codeigniter_route<'a>(
|
||||
call: Node<'a>,
|
||||
bytes: &'a [u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
let object = call.child_by_field_name("object")?.utf8_text(bytes).ok()?;
|
||||
if object.trim_start_matches('$').trim() != "routes" {
|
||||
return None;
|
||||
}
|
||||
let verb = call.child_by_field_name("name")?.utf8_text(bytes).ok()?;
|
||||
let method = verb_method(verb)?;
|
||||
let args = call.child_by_field_name("arguments")?;
|
||||
let path = first_php_string_arg(args, bytes)?;
|
||||
if !codeigniter_callable_matches(args, bytes, target, controller) {
|
||||
return None;
|
||||
}
|
||||
Some((method, path))
|
||||
}
|
||||
|
||||
fn codeigniter_callable_matches(
|
||||
arguments: Node<'_>,
|
||||
bytes: &[u8],
|
||||
target: &str,
|
||||
controller: Option<&str>,
|
||||
) -> bool {
|
||||
let mut cur = arguments.walk();
|
||||
let mut positional: Vec<Node<'_>> = Vec::new();
|
||||
for arg in arguments.named_children(&mut cur) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
if arg.child_by_field_name("name").is_some() {
|
||||
continue;
|
||||
}
|
||||
positional.push(arg);
|
||||
}
|
||||
let Some(callable_arg) = positional.get(1) else {
|
||||
return false;
|
||||
};
|
||||
let Some(value) = callable_arg.named_child(0) else {
|
||||
return false;
|
||||
};
|
||||
match value.kind() {
|
||||
"anonymous_function" | "anonymous_function_creation_expression" | "arrow_function" => true,
|
||||
"string" | "encapsed_string" => {
|
||||
let Some(literal) = string_content(value, bytes) else {
|
||||
return false;
|
||||
};
|
||||
let (ctrl, act) = literal
|
||||
.rsplit_once("::")
|
||||
.map(|(c, a)| (Some(leaf(c).to_owned()), a.to_owned()))
|
||||
.unwrap_or((None, literal));
|
||||
if act != target {
|
||||
return false;
|
||||
}
|
||||
match controller {
|
||||
Some(c) => ctrl.as_deref() == Some(c),
|
||||
None => 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_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_top_level_function() {
|
||||
let src: &[u8] = b"<?php\nfunction target($a) { return $a; }\n";
|
||||
let tree = parse(src);
|
||||
let (node, klass) = find_php_function(tree.root_node(), src, "target").unwrap();
|
||||
assert_eq!(node.kind(), "function_definition");
|
||||
assert!(klass.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_method_with_enclosing_class() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nclass UserController {\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let (node, klass) = find_php_function(tree.root_node(), src, "show").unwrap();
|
||||
assert_eq!(node.kind(), "method_declaration");
|
||||
assert_eq!(klass.unwrap().kind(), "class_declaration");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formal_names_strip_dollar_sigil() {
|
||||
let src: &[u8] = b"<?php\nfunction f($id, $extra) { return $id; }\n";
|
||||
let tree = parse(src);
|
||||
let (func, _) = find_php_function(tree.root_node(), src, "f").unwrap();
|
||||
assert_eq!(php_formal_names(func, src), vec!["id", "extra"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_brace_placeholders() {
|
||||
assert_eq!(extract_php_path_placeholders("/users/{id}"), vec!["id"]);
|
||||
assert_eq!(
|
||||
extract_php_path_placeholders("/u/{id}/p/{slug?}"),
|
||||
vec!["id", "slug"]
|
||||
);
|
||||
assert_eq!(
|
||||
extract_php_path_placeholders("/u/{id:[0-9]+}"),
|
||||
vec!["id"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_codeigniter_placeholders() {
|
||||
assert_eq!(
|
||||
extract_php_path_placeholders("users/(:num)"),
|
||||
vec!["num"]
|
||||
);
|
||||
assert_eq!(
|
||||
extract_php_path_placeholders("p/(:any)/c/(:segment)"),
|
||||
vec!["any", "segment"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_known_placeholder_as_path_segment() {
|
||||
let formals = vec!["id".to_string(), "extra".to_string()];
|
||||
let bindings = bind_php_path_params(&formals, "/users/{id}");
|
||||
assert!(matches!(bindings[0].source, ParamSource::PathSegment(_)));
|
||||
assert!(matches!(bindings[1].source, ParamSource::QueryParam(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_request_as_implicit() {
|
||||
let formals = vec!["request".to_string(), "id".to_string()];
|
||||
let bindings = bind_php_path_params(&formals, "/users/{id}");
|
||||
assert!(matches!(bindings[0].source, ParamSource::Implicit));
|
||||
assert!(matches!(bindings[1].source, ParamSource::PathSegment(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_attributes_visits_each_attribute() {
|
||||
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\nclass C {\n #[Route('/x', methods: ['GET'])]\n public function show($id) {}\n}\n";
|
||||
let tree = parse(src);
|
||||
let (method, _) = find_php_function(tree.root_node(), src, "show").unwrap();
|
||||
let mut hit_name: Option<String> = None;
|
||||
let mut hit_path: Option<String> = None;
|
||||
iter_php_attributes(method, src, |ann, name| {
|
||||
hit_name = Some(name.to_owned());
|
||||
let args = ann.child_by_field_name("parameters").unwrap();
|
||||
hit_path = first_php_string_arg(args, src);
|
||||
});
|
||||
assert_eq!(hit_name.as_deref(), Some("Route"));
|
||||
assert_eq!(hit_path.as_deref(), Some("/x"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn iter_attributes_reads_named_methods_kwarg() {
|
||||
let src: &[u8] = b"<?php\nclass C {\n #[Route('/x', methods: ['POST'])]\n public function save() {}\n}\n";
|
||||
let tree = parse(src);
|
||||
let (method, _) = find_php_function(tree.root_node(), src, "save").unwrap();
|
||||
let mut verb: Option<HttpMethod> = None;
|
||||
iter_php_attributes(method, src, |ann, _| {
|
||||
let args = ann.child_by_field_name("parameters").unwrap();
|
||||
verb = methods_named_arg(args, src);
|
||||
});
|
||||
assert_eq!(verb, Some(HttpMethod::POST));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_laravel_static_route_with_string_callable() {
|
||||
let src: &[u8] = b"<?php\nRoute::get('/users/{id}', 'UserController@show');\nclass UserController {\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let hit = find_laravel_static_route(
|
||||
tree.root_node(),
|
||||
src,
|
||||
"show",
|
||||
Some("UserController"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hit.0, HttpMethod::GET);
|
||||
assert_eq!(hit.1, "/users/{id}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_laravel_static_route_with_closure() {
|
||||
let src: &[u8] = b"<?php\nRoute::post('/users', function ($payload) { return $payload; });\n";
|
||||
let tree = parse(src);
|
||||
let hit = find_laravel_static_route(tree.root_node(), src, "anything", None).unwrap();
|
||||
assert_eq!(hit.0, HttpMethod::POST);
|
||||
assert_eq!(hit.1, "/users");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_codeigniter_member_route() {
|
||||
let src: &[u8] = b"<?php\n$routes->get('users/(:num)', 'UserController::show');\n";
|
||||
let tree = parse(src);
|
||||
let hit = find_codeigniter_route(
|
||||
tree.root_node(),
|
||||
src,
|
||||
"show",
|
||||
Some("UserController"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hit.0, HttpMethod::GET);
|
||||
assert_eq!(hit.1, "users/(:num)");
|
||||
}
|
||||
}
|
||||
181
src/dynamic/framework/adapters/php_symfony.rs
Normal file
181
src/dynamic/framework/adapters/php_symfony.rs
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
//! Symfony [`super::super::FrameworkAdapter`] (Phase 16 — Track L.14).
|
||||
//!
|
||||
//! Recognises `#[Route('/path', methods: ['GET'])]` PHP attributes on
|
||||
//! controller methods or top-level functions. Class-level
|
||||
//! `#[Route('/api')]` prefix is concatenated with the method-level
|
||||
//! path so `#[Route('/api')] + #[Route('/x')]` produces `"/api/x"`.
|
||||
//!
|
||||
//! YAML routing (`config/routes.yaml`) is not handled in v1 — the
|
||||
//! attribute path covers >90% of modern Symfony 5/6/7 controller
|
||||
//! declarations and is the only path the harness needs to bind a
|
||||
//! single route inside a single source file. YAML lookup belongs to
|
||||
//! a later phase once the framework adapter trait gains access to
|
||||
//! the project-level config file list.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
use super::php_routes::{
|
||||
bind_php_path_params, find_php_function, first_php_string_arg, iter_php_attributes,
|
||||
methods_named_arg, php_formal_names, source_imports_symfony,
|
||||
};
|
||||
|
||||
pub struct PhpSymfonyAdapter;
|
||||
|
||||
const ADAPTER_NAME: &str = "php-symfony";
|
||||
|
||||
fn route_attribute_shape(node: Node<'_>, bytes: &[u8]) -> Option<(HttpMethod, String)> {
|
||||
let mut hit: Option<(HttpMethod, String)> = None;
|
||||
iter_php_attributes(node, bytes, |ann, name| {
|
||||
if hit.is_some() || name != "Route" {
|
||||
return;
|
||||
}
|
||||
let Some(args) = ann.child_by_field_name("parameters") else {
|
||||
return;
|
||||
};
|
||||
let path = first_php_string_arg(args, bytes).unwrap_or_default();
|
||||
let method = methods_named_arg(args, bytes).unwrap_or(HttpMethod::GET);
|
||||
hit = Some((method, path));
|
||||
});
|
||||
hit
|
||||
}
|
||||
|
||||
fn join_route_path(class_path: &str, method_path: &str) -> String {
|
||||
if class_path.is_empty() {
|
||||
return method_path.to_owned();
|
||||
}
|
||||
if method_path.is_empty() {
|
||||
return class_path.to_owned();
|
||||
}
|
||||
format!(
|
||||
"{}/{}",
|
||||
class_path.trim_end_matches('/'),
|
||||
method_path.trim_start_matches('/')
|
||||
)
|
||||
}
|
||||
|
||||
impl FrameworkAdapter for PhpSymfonyAdapter {
|
||||
fn name(&self) -> &'static str {
|
||||
ADAPTER_NAME
|
||||
}
|
||||
|
||||
fn lang(&self) -> Lang {
|
||||
Lang::Php
|
||||
}
|
||||
|
||||
fn detect(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_symfony(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let (func_node, class) = find_php_function(ast, file_bytes, &summary.name)?;
|
||||
let (http_method, method_path) = route_attribute_shape(func_node, file_bytes)?;
|
||||
let class_prefix = class
|
||||
.and_then(|c| route_attribute_shape(c, file_bytes))
|
||||
.map(|(_, p)| p)
|
||||
.unwrap_or_default();
|
||||
let path = join_route_path(&class_prefix, &method_path);
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_path_params(&formals, &path);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape {
|
||||
method: http_method,
|
||||
path,
|
||||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::dynamic::framework::ParamSource;
|
||||
|
||||
fn parse(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
parser.set_language(&lang).unwrap();
|
||||
parser.parse(src, None).unwrap()
|
||||
}
|
||||
|
||||
fn summary(name: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
lang: "php".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_method_route_attribute_with_class_prefix() {
|
||||
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\n#[Route('/api')]\nclass UserController {\n #[Route('/users/{id}')]\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpSymfonyAdapter
|
||||
.detect(&summary("show"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.adapter, "php-symfony");
|
||||
let route = binding.route.expect("route");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/api/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_named_methods_kwarg() {
|
||||
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\nclass C {\n #[Route('/save', methods: ['POST'])]\n public function save($payload) { return $payload; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpSymfonyAdapter
|
||||
.detect(&summary("save"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(route.path, "/save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_function_level_attribute() {
|
||||
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\n#[Route('/x')]\nfunction handle() { return 'ok'; }\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpSymfonyAdapter
|
||||
.detect(&summary("handle"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.path, "/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_symfony_not_imported() {
|
||||
let src: &[u8] = b"<?php\n#[Route('/x')]\nfunction f() { return 1; }\n";
|
||||
let tree = parse(src);
|
||||
assert!(PhpSymfonyAdapter
|
||||
.detect(&summary("f"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_method_has_no_route_attribute() {
|
||||
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\nclass C {\n public function helper($x) { return $x; }\n}\n";
|
||||
let tree = parse(src);
|
||||
assert!(PhpSymfonyAdapter
|
||||
.detect(&summary("helper"), tree.root_node(), src)
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -214,13 +214,13 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn registry_baseline_after_phase_15() {
|
||||
// Phase 15 (Track L.13) adds three Ruby framework adapters
|
||||
// (`ruby-hanami`, `ruby-rails`, `ruby-sinatra`) to the Ruby
|
||||
// slice, growing it from 5 → 8. The Phase 14 baseline for
|
||||
// the other languages stays put: Java 11, Python 11, Php 7,
|
||||
// JavaScript 11, TypeScript 4, Go 3, Rust 2. C / Cpp stay
|
||||
// empty.
|
||||
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.
|
||||
let java_registered = registry::adapters_for(Lang::Java);
|
||||
assert_eq!(
|
||||
java_registered.len(),
|
||||
|
|
@ -233,8 +233,8 @@ mod tests {
|
|||
let php_registered = registry::adapters_for(Lang::Php);
|
||||
assert_eq!(
|
||||
php_registered.len(),
|
||||
7,
|
||||
"Php must have the J.1+J.2+J.3+J.4+J.5+J.6+J.7 adapters",
|
||||
10,
|
||||
"Php must have J.1..J.7 (7) + L.14 Laravel/Symfony/CodeIgniter (3) adapters",
|
||||
);
|
||||
for adapter in php_registered {
|
||||
assert_eq!(adapter.lang(), Lang::Php);
|
||||
|
|
|
|||
|
|
@ -71,6 +71,9 @@ static GO: &[&dyn FrameworkAdapter] = &[
|
|||
static PHP: &[&dyn FrameworkAdapter] = &[
|
||||
&super::adapters::HeaderPhpAdapter,
|
||||
&super::adapters::LdapPhpAdapter,
|
||||
&super::adapters::PhpCodeIgniterAdapter,
|
||||
&super::adapters::PhpLaravelAdapter,
|
||||
&super::adapters::PhpSymfonyAdapter,
|
||||
&super::adapters::PhpTwigAdapter,
|
||||
&super::adapters::PhpUnserializeAdapter,
|
||||
&super::adapters::RedirectPhpAdapter,
|
||||
|
|
|
|||
|
|
@ -134,11 +134,28 @@ fn php_string_literal(s: &str) -> String {
|
|||
/// preserving the pre-Phase-15 behaviour (direct function call).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PhpShape {
|
||||
/// Slim / Laravel / Symfony route closure. Harness builds a
|
||||
/// minimal request stub (query/body) and invokes the closure
|
||||
/// resolved from `$GLOBALS['__nyx_route']` (which the entry file
|
||||
/// publishes during include).
|
||||
/// Slim / generic route closure published via
|
||||
/// `$GLOBALS['__nyx_route']`. Harness builds a minimal request
|
||||
/// stub (query/body) and invokes the closure resolved from the
|
||||
/// global (which the entry file publishes during include).
|
||||
RouteClosure,
|
||||
/// Laravel route — `Route::get('/x', 'Controller@method')` or
|
||||
/// closure callable. Phase 16 v1 dispatches through the same
|
||||
/// `$GLOBALS['__nyx_route']` channel as `RouteClosure` but
|
||||
/// publishes a `NYX_LARAVEL_TEST=1` stdout marker so the
|
||||
/// verifier can confirm the framework toolchain knob propagated.
|
||||
LaravelRoute,
|
||||
/// Symfony route — `#[Route('/x')]` PHP attribute on a
|
||||
/// controller method or top-level function. Phase 16 v1
|
||||
/// dispatches via reflective invocation (the entry file's
|
||||
/// `entry.php` instantiates the controller class and the harness
|
||||
/// calls the method) plus an `NYX_SYMFONY_TEST=1` stdout marker.
|
||||
SymfonyRoute,
|
||||
/// CodeIgniter route — `$routes->get('users/(:num)', ...)`
|
||||
/// published from `app/Config/Routes.php`. Phase 16 v1
|
||||
/// dispatches via the `$GLOBALS['__nyx_route']` channel plus a
|
||||
/// `NYX_CODEIGNITER_TEST=1` stdout marker.
|
||||
CodeIgniterRoute,
|
||||
/// CLI script driven by `$argv`. Harness mutates `$argv` then
|
||||
/// includes the entry file (whose top-level body reads `$argv`),
|
||||
/// or — when the spec names a function — calls the function after
|
||||
|
|
@ -159,15 +176,37 @@ impl PhpShape {
|
|||
let entry = spec.entry_name.as_str();
|
||||
let kind = spec.entry_kind;
|
||||
|
||||
let has_symfony_marker = source.contains("#[Route(")
|
||||
|| source.contains("Symfony\\Component\\Routing")
|
||||
|| source.contains("Symfony\\Component\\HttpKernel")
|
||||
|| source.contains("// nyx-shape: symfony");
|
||||
let has_laravel_marker = source.contains("Illuminate\\Support\\Facades\\Route")
|
||||
|| source.contains("Illuminate\\Routing")
|
||||
|| source.contains("Route::get(")
|
||||
|| source.contains("Route::post(")
|
||||
|| source.contains("Route::put(")
|
||||
|| source.contains("Route::patch(")
|
||||
|| source.contains("Route::delete(")
|
||||
|| source.contains("Route::any(")
|
||||
|| source.contains("Route::match(")
|
||||
|| source.contains("App\\Http\\Controllers")
|
||||
|| source.contains("// nyx-shape: laravel");
|
||||
let has_codeigniter_marker = source.contains("CodeIgniter\\Router")
|
||||
|| source.contains("CodeIgniter\\HTTP")
|
||||
|| source.contains("$routes->get(")
|
||||
|| source.contains("$routes->post(")
|
||||
|| source.contains("$routes->put(")
|
||||
|| source.contains("$routes->patch(")
|
||||
|| source.contains("$routes->delete(")
|
||||
|| source.contains("$routes->add(")
|
||||
|| source.contains("extends BaseController")
|
||||
|| source.contains("// nyx-shape: codeigniter");
|
||||
let has_route_marker = source.contains("$app->get(")
|
||||
|| source.contains("$app->post(")
|
||||
|| source.contains("$app->any(")
|
||||
|| source.contains("$app->map(")
|
||||
|| source.contains("$router->get(")
|
||||
|| source.contains("$router->post(")
|
||||
|| source.contains("Route::get(")
|
||||
|| source.contains("Route::post(")
|
||||
|| source.contains("Route::any(")
|
||||
|| source.contains("// nyx-shape: route");
|
||||
let has_argv = source.contains("$argv") || source.contains("// nyx-shape: cli");
|
||||
let has_function_decl = source.contains("function ")
|
||||
|
|
@ -177,6 +216,15 @@ impl PhpShape {
|
|||
&& !entry.is_empty()
|
||||
&& source.contains(&format!("function {entry}"));
|
||||
|
||||
if has_symfony_marker {
|
||||
return Self::SymfonyRoute;
|
||||
}
|
||||
if has_laravel_marker {
|
||||
return Self::LaravelRoute;
|
||||
}
|
||||
if has_codeigniter_marker {
|
||||
return Self::CodeIgniterRoute;
|
||||
}
|
||||
if has_route_marker {
|
||||
return Self::RouteClosure;
|
||||
}
|
||||
|
|
@ -982,11 +1030,12 @@ fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String {
|
|||
let entry_block = build_entry_block(shape);
|
||||
let call_expr = build_call_expr(spec, shape, entry_fn);
|
||||
let shim = probe_shim();
|
||||
let toolchain_marker = build_toolchain_marker(shape);
|
||||
let crash_callee = if entry_fn.is_empty() { "main" } else { entry_fn.as_str() };
|
||||
|
||||
format!(
|
||||
r#"<?php
|
||||
// Nyx dynamic harness — auto-generated, do not edit (Phase 15 — PhpShape::{shape:?}).
|
||||
// Nyx dynamic harness — auto-generated, do not edit (Phase 16 — PhpShape::{shape:?}).
|
||||
{shim}
|
||||
// ── Payload loading ────────────────────────────────────────────────────────────
|
||||
function nyx_payload(): string {{
|
||||
|
|
@ -1013,7 +1062,8 @@ __nyx_install_crash_guard('{crash_callee}');
|
|||
{pre_call}
|
||||
// ── Entry include ─────────────────────────────────────────────────────────────
|
||||
{entry_block}
|
||||
// ── Call entry point ──────────────────────────────────────────────────────────
|
||||
// ── Framework toolchain marker (Phase 16 — Track L.14) ────────────────────────
|
||||
{toolchain_marker}// ── Call entry point ──────────────────────────────────────────────────────────
|
||||
try {{
|
||||
$result = {call_expr};
|
||||
if ($result !== null) {{
|
||||
|
|
@ -1028,6 +1078,7 @@ try {{
|
|||
entry_block = entry_block,
|
||||
call_expr = call_expr,
|
||||
shim = shim,
|
||||
toolchain_marker = toolchain_marker,
|
||||
crash_callee = crash_callee,
|
||||
)
|
||||
}
|
||||
|
|
@ -1100,7 +1151,9 @@ fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String {
|
|||
"null".to_owned()
|
||||
}
|
||||
}
|
||||
PhpShape::RouteClosure => {
|
||||
PhpShape::RouteClosure
|
||||
| PhpShape::LaravelRoute
|
||||
| PhpShape::CodeIgniterRoute => {
|
||||
// Entry script publishes the route closure via
|
||||
// `$GLOBALS['__nyx_route']`. When the global is missing,
|
||||
// fall back to calling the named function directly.
|
||||
|
|
@ -1108,10 +1161,35 @@ fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String {
|
|||
"(isset($GLOBALS['__nyx_route']) && is_callable($GLOBALS['__nyx_route'])) ? call_user_func($GLOBALS['__nyx_route'], $payload) : (function_exists({func:?}) ? {func}($payload) : null)"
|
||||
)
|
||||
}
|
||||
PhpShape::SymfonyRoute => {
|
||||
// Symfony controllers are normally reached through
|
||||
// `HttpKernel::handle`. The Phase 16 v1 harness drives
|
||||
// the action directly: the entry file publishes a
|
||||
// controller instance via `$GLOBALS['__nyx_controller']`
|
||||
// and the harness reflectively invokes the action method.
|
||||
// Falls back to calling a bare function when no
|
||||
// controller class was published.
|
||||
format!(
|
||||
"(isset($GLOBALS['__nyx_controller']) && is_object($GLOBALS['__nyx_controller'])) ? $GLOBALS['__nyx_controller']->{func}($payload) : (function_exists({func:?}) ? {func}($payload) : null)"
|
||||
)
|
||||
}
|
||||
PhpShape::Generic => build_generic_call(spec, func),
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-shape stdout toolchain markers. Mirrors the Phase 14
|
||||
/// `JavaShape::SpringController` `NYX_SPRING_TEST` stdout marker so
|
||||
/// the verifier can confirm a framework knob propagated through to
|
||||
/// the harness — even though the v1 invocation path is reflective.
|
||||
fn build_toolchain_marker(shape: PhpShape) -> &'static str {
|
||||
match shape {
|
||||
PhpShape::LaravelRoute => "echo \"NYX_LARAVEL_TEST=1\\n\";\n",
|
||||
PhpShape::SymfonyRoute => "echo \"NYX_SYMFONY_TEST=1\\n\";\n",
|
||||
PhpShape::CodeIgniterRoute => "echo \"NYX_CODEIGNITER_TEST=1\\n\";\n",
|
||||
_ => "",
|
||||
}
|
||||
}
|
||||
|
||||
fn build_generic_call(spec: &HarnessSpec, func: &str) -> String {
|
||||
match &spec.payload_slot {
|
||||
PayloadSlot::Param(idx) => {
|
||||
|
|
@ -1259,9 +1337,52 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn shape_detect_laravel_route_closure() {
|
||||
// Phase 16 reroutes Laravel-marker sources to the dedicated
|
||||
// LaravelRoute shape so the harness can emit the
|
||||
// `NYX_LARAVEL_TEST=1` toolchain stdout marker (mirroring the
|
||||
// Phase 14 Spring `NYX_SPRING_TEST=1` channel).
|
||||
let src = "<?php\nRoute::get('/run', function ($payload) { return $payload; });\n";
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
|
||||
assert_eq!(PhpShape::detect(&spec, src), PhpShape::RouteClosure);
|
||||
assert_eq!(PhpShape::detect(&spec, src), PhpShape::LaravelRoute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_detect_symfony_route_attribute() {
|
||||
let src = "<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\nclass C {\n #[Route('/run')]\n public function run($p) { return $p; }\n}\n";
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
|
||||
assert_eq!(PhpShape::detect(&spec, src), PhpShape::SymfonyRoute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shape_detect_codeigniter_route() {
|
||||
let src = "<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->get('run', 'UserController::run');\n";
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
|
||||
assert_eq!(PhpShape::detect(&spec, src), PhpShape::CodeIgniterRoute);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_shape_emits_toolchain_marker() {
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
|
||||
let src = generate_source(&spec, PhpShape::LaravelRoute);
|
||||
assert!(src.contains("NYX_LARAVEL_TEST=1"));
|
||||
assert!(src.contains("$GLOBALS['__nyx_route']"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symfony_shape_emits_toolchain_marker_and_controller_dispatch() {
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
|
||||
let src = generate_source(&spec, PhpShape::SymfonyRoute);
|
||||
assert!(src.contains("NYX_SYMFONY_TEST=1"));
|
||||
assert!(src.contains("$GLOBALS['__nyx_controller']"));
|
||||
assert!(src.contains("->run($payload)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codeigniter_shape_emits_toolchain_marker() {
|
||||
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php");
|
||||
let src = generate_source(&spec, PhpShape::CodeIgniterRoute);
|
||||
assert!(src.contains("NYX_CODEIGNITER_TEST=1"));
|
||||
assert!(src.contains("$GLOBALS['__nyx_route']"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
18
tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php
Normal file
18
tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
// Phase 16 — CodeIgniter-style route, benign sanitised payload.
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
$routes->get('run', 'UserController::run');
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
public function run($payload)
|
||||
{
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$cmd = "echo hello " . escapeshellarg($payload);
|
||||
$out = shell_exec($cmd);
|
||||
echo $out;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "nyx/fixture-codeigniter",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"codeigniter4/framework": "^4.4"
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php
Normal file
20
tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
// Phase 16 — CodeIgniter-style route, vulnerable.
|
||||
// `$routes->get('run', 'UserController::run')` references the
|
||||
// controller method whose body shells out without sanitisation.
|
||||
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
$routes->get('run', 'UserController::run');
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
public function run($payload)
|
||||
{
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$cmd = "echo hello " . $payload;
|
||||
$out = shell_exec($cmd);
|
||||
echo $out;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
18
tests/dynamic_fixtures/php_frameworks/laravel/benign.php
Normal file
18
tests/dynamic_fixtures/php_frameworks/laravel/benign.php
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
// Phase 16 — Laravel-style route, benign sanitised payload.
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/run', 'UserController@run');
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function run($payload)
|
||||
{
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$cmd = "echo hello " . escapeshellarg($payload);
|
||||
$out = shell_exec($cmd);
|
||||
echo $out;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"name": "nyx/fixture-laravel",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"laravel/framework": "^11.0"
|
||||
}
|
||||
}
|
||||
20
tests/dynamic_fixtures/php_frameworks/laravel/vuln.php
Normal file
20
tests/dynamic_fixtures/php_frameworks/laravel/vuln.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
// Phase 16 — Laravel-style route, vulnerable.
|
||||
// `Route::get('/run', 'UserController@run')` references the
|
||||
// controller method whose body shells out without sanitisation.
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/run', 'UserController@run');
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function run($payload)
|
||||
{
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$cmd = "echo hello " . $payload;
|
||||
$out = shell_exec($cmd);
|
||||
echo $out;
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
21
tests/dynamic_fixtures/php_frameworks/symfony/benign.php
Normal file
21
tests/dynamic_fixtures/php_frameworks/symfony/benign.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
// Phase 16 — Symfony-style route via `#[Route]` attribute,
|
||||
// benign sanitised payload.
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class UserController
|
||||
{
|
||||
#[Route('/run', methods: ['GET'])]
|
||||
public function run($payload)
|
||||
{
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$cmd = "echo hello " . escapeshellarg($payload);
|
||||
$out = shell_exec($cmd);
|
||||
echo $out;
|
||||
return new Response($out);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"name": "nyx/fixture-symfony",
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/framework-bundle": "^7.0",
|
||||
"symfony/routing": "^7.0",
|
||||
"symfony/http-kernel": "^7.0"
|
||||
}
|
||||
}
|
||||
21
tests/dynamic_fixtures/php_frameworks/symfony/vuln.php
Normal file
21
tests/dynamic_fixtures/php_frameworks/symfony/vuln.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
// Phase 16 — Symfony-style route via `#[Route]` attribute,
|
||||
// vulnerable.
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class UserController
|
||||
{
|
||||
#[Route('/run', methods: ['GET'])]
|
||||
public function run($payload)
|
||||
{
|
||||
echo "__NYX_SINK_HIT__\n";
|
||||
$cmd = "echo hello " . $payload;
|
||||
$out = shell_exec($cmd);
|
||||
echo $out;
|
||||
return new Response($out);
|
||||
}
|
||||
}
|
||||
137
tests/php_frameworks_corpus.rs
Normal file
137
tests/php_frameworks_corpus.rs
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
//! Phase 16 (Track L.14) — PHP framework adapter integration tests.
|
||||
//!
|
||||
//! Each test exercises `detect_binding` end-to-end against a fixture
|
||||
//! file under `tests/dynamic_fixtures/php_frameworks/`, asserting
|
||||
//! that the right adapter fires, the binding carries
|
||||
//! `EntryKind::HttpRoute`, and the `RouteShape` + per-formal
|
||||
//! `request_params` match the brief's contract. Benign fixtures
|
||||
//! must produce the same adapter binding shape as the vuln fixtures
|
||||
//! — the adapter only models the route, the differential outcome of
|
||||
//! a verifier run is what distinguishes the two.
|
||||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource};
|
||||
use nyx_scanner::evidence::EntryKind;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
||||
fn parse_php(src: &[u8]) -> tree_sitter::Tree {
|
||||
let mut parser = tree_sitter::Parser::new();
|
||||
let lang = tree_sitter::Language::from(tree_sitter_php::LANGUAGE_PHP);
|
||||
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: "php".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_vuln_fixture_binds_route() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/laravel/vuln.php";
|
||||
let bytes = std::fs::read(path).expect("laravel vuln fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("run", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("laravel adapter must bind");
|
||||
assert_eq!(binding.adapter, "php-laravel");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
let payload = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "payload")
|
||||
.expect("payload formal");
|
||||
assert!(matches!(payload.source, ParamSource::QueryParam(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/laravel/benign.php";
|
||||
let bytes = std::fs::read(path).expect("laravel benign fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("run", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("laravel adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "php-laravel");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symfony_vuln_fixture_binds_route_via_attribute() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/symfony/vuln.php";
|
||||
let bytes = std::fs::read(path).expect("symfony vuln fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("run", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("symfony adapter must bind");
|
||||
assert_eq!(binding.adapter, "php-symfony");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symfony_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/symfony/benign.php";
|
||||
let bytes = std::fs::read(path).expect("symfony benign fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("run", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("symfony adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "php-symfony");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codeigniter_vuln_fixture_binds_route() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php";
|
||||
let bytes = std::fs::read(path).expect("codeigniter vuln fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("run", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("codeigniter adapter must bind");
|
||||
assert_eq!(binding.adapter, "php-codeigniter");
|
||||
assert_eq!(binding.kind, EntryKind::HttpRoute);
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "run");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codeigniter_benign_fixture_binds_same_route_shape() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/benign.php";
|
||||
let bytes = std::fs::read(path).expect("codeigniter benign fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("run", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("codeigniter adapter must bind benign fixture");
|
||||
assert_eq!(binding.adapter, "php-codeigniter");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_adapter_ignores_helper_method() {
|
||||
// `helper` is declared but not referenced in any `Route::*` call.
|
||||
// The adapter must return `None` so the verifier surfaces
|
||||
// `SpecDerivationFailed` for non-route helpers in a route file.
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/laravel/vuln.php";
|
||||
let bytes = std::fs::read(path).expect("laravel vuln fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("nonexistent_helper", path);
|
||||
let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Php);
|
||||
assert!(binding.is_none());
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue