mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
refactor(dynamic): add cross-file route detection for frameworks, enhance test coverage in PHP and Ruby
This commit is contained in:
parent
43ab4aa469
commit
0e8c900078
22 changed files with 1208 additions and 134 deletions
|
|
@ -13,7 +13,9 @@
|
|||
|
||||
#[cfg(test)]
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, RouteShape};
|
||||
use crate::dynamic::framework::{
|
||||
FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, ProjectFileIndex, RouteShape,
|
||||
};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
|
|
@ -44,7 +46,7 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter {
|
|||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_codeigniter(summary, None, ast, file_bytes)
|
||||
detect_codeigniter(summary, None, ast, file_bytes, None)
|
||||
}
|
||||
|
||||
fn detect_with_context(
|
||||
|
|
@ -54,7 +56,23 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter {
|
|||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_codeigniter(summary, ssa_summary, ast, file_bytes)
|
||||
detect_codeigniter(summary, ssa_summary, ast, file_bytes, None)
|
||||
}
|
||||
|
||||
fn detect_with_project_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
context: FrameworkDetectionContext<'_>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_codeigniter(
|
||||
summary,
|
||||
context.ssa_summary,
|
||||
ast,
|
||||
file_bytes,
|
||||
Some(context.project_files),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -63,10 +81,8 @@ fn detect_codeigniter(
|
|||
ssa_summary: Option<&SsaFuncSummary>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
project_files: Option<&ProjectFileIndex>,
|
||||
) -> Option<FrameworkBinding> {
|
||||
if !source_imports_codeigniter(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
if !super::typed_receiver_facts_allow(
|
||||
summary,
|
||||
ssa_summary,
|
||||
|
|
@ -78,11 +94,26 @@ fn detect_codeigniter(
|
|||
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 (method, path, from_project_config) = if let Some((method, path)) =
|
||||
find_codeigniter_route(ast, file_bytes, &summary.name, controller)
|
||||
{
|
||||
(method, path, false)
|
||||
} else {
|
||||
let (method, path) = project_files
|
||||
.and_then(|files| codeigniter_config_route(files, &summary.name, controller))?;
|
||||
(method, path, true)
|
||||
};
|
||||
|
||||
if !source_imports_codeigniter(file_bytes) && !from_project_config {
|
||||
return None;
|
||||
}
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_path_params(&formals, &path);
|
||||
let middleware = collect_php_middleware(ast, file_bytes);
|
||||
let mut middleware = collect_php_middleware(ast, file_bytes);
|
||||
if from_project_config && let Some(files) = project_files {
|
||||
middleware.extend(codeigniter_config_middleware(files));
|
||||
}
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -94,6 +125,35 @@ fn detect_codeigniter(
|
|||
})
|
||||
}
|
||||
|
||||
fn codeigniter_config_route(
|
||||
project_files: &ProjectFileIndex,
|
||||
method_name: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<(crate::dynamic::framework::HttpMethod, String)> {
|
||||
let bytes = project_files.get("app/Config/Routes.php")?;
|
||||
let tree = parse_php(bytes)?;
|
||||
find_codeigniter_route(tree.root_node(), bytes, method_name, controller)
|
||||
}
|
||||
|
||||
fn codeigniter_config_middleware(
|
||||
project_files: &ProjectFileIndex,
|
||||
) -> Vec<crate::dynamic::framework::MiddlewareShape> {
|
||||
let Some(bytes) = project_files.get("app/Config/Routes.php") else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(tree) = parse_php(bytes) else {
|
||||
return Vec::new();
|
||||
};
|
||||
collect_php_middleware(tree.root_node(), bytes)
|
||||
}
|
||||
|
||||
fn parse_php(bytes: &[u8]) -> Option<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).ok()?;
|
||||
parser.parse(bytes, None)
|
||||
}
|
||||
|
||||
fn callee_is_codeigniter_route_registration(name: &str) -> bool {
|
||||
let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name);
|
||||
matches!(last, "get" | "post" | "put" | "patch" | "delete" | "add")
|
||||
|
|
@ -125,6 +185,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn summary_at(name: &str, file_path: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: file_path.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";
|
||||
|
|
@ -155,6 +224,32 @@ mod tests {
|
|||
assert_eq!(binding.route.unwrap().method, HttpMethod::POST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_project_config_routes_file() {
|
||||
let src: &[u8] = b"<?php\nnamespace App\\Controllers;\nclass UserController extends BaseController {\n public function show($num) { return $num; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert(
|
||||
"app/Config/Routes.php",
|
||||
b"<?php\nuse CodeIgniter\\Router\\RouteCollection;\n$routes->get('users/(:num)', 'UserController::show');\n".to_vec(),
|
||||
);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding = PhpCodeIgniterAdapter
|
||||
.detect_with_project_context(
|
||||
&summary_at("show", "/tmp/app/app/Controllers/UserController.php"),
|
||||
context,
|
||||
tree.root_node(),
|
||||
src,
|
||||
)
|
||||
.expect("binding from app/Config/Routes.php");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "users/(:num)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_codeigniter_not_imported() {
|
||||
let src: &[u8] = b"<?php\n$routes->get('users/(:num)', 'UserController::show');\n";
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
|
||||
#[cfg(test)]
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding};
|
||||
use crate::dynamic::framework::{
|
||||
FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, ProjectFileIndex, RouteShape,
|
||||
};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
|
@ -44,27 +46,98 @@ impl FrameworkAdapter for PhpLaravelAdapter {
|
|||
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 route = find_laravel_static_route_shape(ast, file_bytes, &summary.name, controller)?;
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_path_params(&formals, &route.path);
|
||||
let middleware = collect_php_middleware(ast, file_bytes);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(route),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
})
|
||||
detect_laravel(summary, ast, file_bytes, None)
|
||||
}
|
||||
|
||||
fn detect_with_project_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
context: FrameworkDetectionContext<'_>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_laravel(summary, ast, file_bytes, Some(context.project_files))
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_laravel(
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
project_files: Option<&ProjectFileIndex>,
|
||||
) -> Option<FrameworkBinding> {
|
||||
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 (route, from_project_config) = if let Some(route) =
|
||||
find_laravel_static_route_shape(ast, file_bytes, &summary.name, controller)
|
||||
{
|
||||
(route, false)
|
||||
} else {
|
||||
(
|
||||
project_files
|
||||
.and_then(|files| laravel_config_route_shape(files, &summary.name, controller))?,
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
if !source_imports_laravel(file_bytes) && !from_project_config {
|
||||
return None;
|
||||
}
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_path_params(&formals, &route.path);
|
||||
let mut middleware = collect_php_middleware(ast, file_bytes);
|
||||
if from_project_config && let Some(files) = project_files {
|
||||
middleware.extend(laravel_config_middleware(files));
|
||||
}
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(route),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
|
||||
fn laravel_config_route_shape(
|
||||
project_files: &ProjectFileIndex,
|
||||
method_name: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<RouteShape> {
|
||||
for rel in ["routes/web.php", "routes/api.php"] {
|
||||
if let Some(bytes) = project_files.get(rel)
|
||||
&& let Some(tree) = parse_php(bytes)
|
||||
&& let Some(route) =
|
||||
find_laravel_static_route_shape(tree.root_node(), bytes, method_name, controller)
|
||||
{
|
||||
return Some(route);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn laravel_config_middleware(
|
||||
project_files: &ProjectFileIndex,
|
||||
) -> Vec<crate::dynamic::framework::MiddlewareShape> {
|
||||
let mut out = Vec::new();
|
||||
for rel in ["routes/web.php", "routes/api.php"] {
|
||||
if let Some(bytes) = project_files.get(rel)
|
||||
&& let Some(tree) = parse_php(bytes)
|
||||
{
|
||||
out.extend(collect_php_middleware(tree.root_node(), bytes));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn parse_php(bytes: &[u8]) -> Option<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).ok()?;
|
||||
parser.parse(bytes, None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -87,6 +160,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn summary_at(name: &str, file_path: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: file_path.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";
|
||||
|
|
@ -139,6 +221,37 @@ mod tests {
|
|||
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_project_route_file_for_controller_method() {
|
||||
let src: &[u8] = b"<?php\nnamespace App\\Http\\Controllers;\nclass UserController {\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert(
|
||||
"routes/web.php",
|
||||
b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nuse App\\Http\\Controllers\\UserController;\nRoute::get('/users/{id}', [UserController::class, 'show'])->middleware('auth');\n".to_vec(),
|
||||
);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect_with_project_context(
|
||||
&summary_at("show", "/tmp/app/app/Http/Controllers/UserController.php"),
|
||||
context,
|
||||
tree.root_node(),
|
||||
src,
|
||||
)
|
||||
.expect("binding from routes/web.php");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/users/{id}");
|
||||
assert!(
|
||||
binding.middleware.iter().any(|m| m.name == "auth"),
|
||||
"expected auth middleware from routes/web.php, got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_match_route_methods() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::match(['POST', 'PATCH'], '/jobs/{id}', [JobController::class, 'run']);\nclass JobController {\n public function run($id) { return $id; }\n}\n";
|
||||
|
|
|
|||
|
|
@ -5,14 +5,14 @@
|
|||
//! `#[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.
|
||||
//! The adapter also recognises project `config/routes.yaml` /
|
||||
//! `config/routes.yml` entries when detection receives a project-file
|
||||
//! context.
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||
use crate::dynamic::framework::{
|
||||
FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, HttpMethod, ProjectFileIndex,
|
||||
RouteShape,
|
||||
};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
|
@ -20,7 +20,8 @@ use tree_sitter::Node;
|
|||
|
||||
use super::php_routes::{
|
||||
bind_php_path_params, collect_php_middleware, find_php_function, first_php_string_arg,
|
||||
iter_php_attributes, methods_named_arg, php_formal_names, source_imports_symfony,
|
||||
iter_php_attributes, methods_named_arg, php_class_name, php_formal_names,
|
||||
source_imports_symfony,
|
||||
};
|
||||
|
||||
pub struct PhpSymfonyAdapter;
|
||||
|
|
@ -72,28 +73,185 @@ impl FrameworkAdapter for PhpSymfonyAdapter {
|
|||
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);
|
||||
let middleware = collect_php_middleware(ast, file_bytes);
|
||||
detect_symfony(summary, ast, file_bytes, None)
|
||||
}
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
})
|
||||
fn detect_with_project_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
context: FrameworkDetectionContext<'_>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_symfony(summary, ast, file_bytes, Some(context.project_files))
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_symfony(
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
project_files: Option<&ProjectFileIndex>,
|
||||
) -> Option<FrameworkBinding> {
|
||||
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 (route, from_project_config) =
|
||||
if let Some((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();
|
||||
(
|
||||
Some(RouteShape::single(
|
||||
http_method,
|
||||
join_route_path(&class_prefix, &method_path),
|
||||
)),
|
||||
false,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
project_files.and_then(|files| yaml_route_shape(files, &summary.name, controller)),
|
||||
true,
|
||||
)
|
||||
};
|
||||
|
||||
let route = route?;
|
||||
if !source_imports_symfony(file_bytes) && !from_project_config {
|
||||
return None;
|
||||
}
|
||||
|
||||
let formals = php_formal_names(func_node, file_bytes);
|
||||
let request_params = bind_php_path_params(&formals, &route.path);
|
||||
let middleware = collect_php_middleware(ast, file_bytes);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(route),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
|
||||
fn yaml_route_shape(
|
||||
project_files: &ProjectFileIndex,
|
||||
method_name: &str,
|
||||
controller: Option<&str>,
|
||||
) -> Option<RouteShape> {
|
||||
for rel in ["config/routes.yaml", "config/routes.yml"] {
|
||||
if let Some(bytes) = project_files.get(rel)
|
||||
&& let Some(shape) = parse_symfony_yaml_routes(bytes, method_name, controller)
|
||||
{
|
||||
return Some(shape);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SymfonyYamlRoute {
|
||||
path: Option<String>,
|
||||
controller: Option<String>,
|
||||
method: Option<HttpMethod>,
|
||||
}
|
||||
|
||||
fn parse_symfony_yaml_routes(
|
||||
bytes: &[u8],
|
||||
method_name: &str,
|
||||
class_name: Option<&str>,
|
||||
) -> Option<RouteShape> {
|
||||
let text = std::str::from_utf8(bytes).ok()?;
|
||||
let mut current: Option<SymfonyYamlRoute> = None;
|
||||
for raw in text.lines() {
|
||||
let line = raw.trim_end();
|
||||
let trim = line.trim_start();
|
||||
if trim.is_empty() || trim.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let indent = line.len().saturating_sub(trim.len());
|
||||
if indent == 0 && trim.ends_with(':') {
|
||||
if let Some(shape) = finish_yaml_route(current.take(), method_name, class_name) {
|
||||
return Some(shape);
|
||||
}
|
||||
current = Some(SymfonyYamlRoute::default());
|
||||
continue;
|
||||
}
|
||||
let Some(route) = current.as_mut() else {
|
||||
continue;
|
||||
};
|
||||
let Some((key, value)) = trim.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let value = yaml_scalar(value);
|
||||
match key.trim() {
|
||||
"path" => route.path = Some(value),
|
||||
"controller" | "_controller" => route.controller = Some(value),
|
||||
"methods" => route.method = yaml_method(&value),
|
||||
"defaults" => {
|
||||
if let Some(controller) = inline_yaml_value(&value, "_controller") {
|
||||
route.controller = Some(controller);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
finish_yaml_route(current, method_name, class_name)
|
||||
}
|
||||
|
||||
fn finish_yaml_route(
|
||||
route: Option<SymfonyYamlRoute>,
|
||||
method_name: &str,
|
||||
class_name: Option<&str>,
|
||||
) -> Option<RouteShape> {
|
||||
let route = route?;
|
||||
let path = route.path?;
|
||||
let controller = route.controller?;
|
||||
if !controller_matches(&controller, method_name, class_name) {
|
||||
return None;
|
||||
}
|
||||
Some(RouteShape::single(
|
||||
route.method.unwrap_or(HttpMethod::GET),
|
||||
path,
|
||||
))
|
||||
}
|
||||
|
||||
fn yaml_scalar(value: &str) -> String {
|
||||
value.trim().trim_matches('"').trim_matches('\'').to_owned()
|
||||
}
|
||||
|
||||
fn inline_yaml_value(value: &str, key: &str) -> Option<String> {
|
||||
let trimmed = value.trim().trim_start_matches('{').trim_end_matches('}');
|
||||
for part in trimmed.split(',') {
|
||||
let (k, v) = part.split_once(':')?;
|
||||
if k.trim() == key {
|
||||
return Some(yaml_scalar(v));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn yaml_method(value: &str) -> Option<HttpMethod> {
|
||||
for raw in value.trim_matches('[').trim_matches(']').split([',', ' ']) {
|
||||
let token = raw.trim().trim_matches('"').trim_matches('\'');
|
||||
if let Some(method) = HttpMethod::from_ident(token) {
|
||||
return Some(method);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn controller_matches(controller: &str, method_name: &str, class_name: Option<&str>) -> bool {
|
||||
let controller = controller.trim();
|
||||
let Some((class, method)) = controller.rsplit_once("::") else {
|
||||
return false;
|
||||
};
|
||||
if method != method_name {
|
||||
return false;
|
||||
}
|
||||
match class_name {
|
||||
Some(expected) => class.rsplit('\\').next().unwrap_or(class) == expected,
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -117,6 +275,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn summary_at(name: &str, file_path: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: file_path.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";
|
||||
|
|
@ -159,6 +326,38 @@ mod tests {
|
|||
assert_eq!(route.path, "/x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_project_yaml_route_config() {
|
||||
let src: &[u8] = b"<?php\nnamespace App\\Controller;\nuse Symfony\\Bundle\\FrameworkBundle\\Controller\\AbstractController;\nclass ReportController extends AbstractController {\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert(
|
||||
"config/routes.yaml",
|
||||
b"report_show:\n path: /reports/{id}\n controller: App\\Controller\\ReportController::show\n methods: [POST]\n".to_vec(),
|
||||
);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding = PhpSymfonyAdapter
|
||||
.detect_with_project_context(
|
||||
&summary_at("show", "/tmp/app/src/Controller/ReportController.php"),
|
||||
context,
|
||||
tree.root_node(),
|
||||
src,
|
||||
)
|
||||
.expect("binding from config/routes.yaml");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.path, "/reports/{id}");
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
let id = binding
|
||||
.request_params
|
||||
.iter()
|
||||
.find(|p| p.name == "id")
|
||||
.unwrap();
|
||||
assert!(matches!(id.source, ParamSource::PathSegment(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_is_granted_attribute() {
|
||||
let src: &[u8] = b"<?php\nuse Symfony\\Component\\Routing\\Annotation\\Route;\nclass C {\n #[Route('/x')]\n #[IsGranted('ROLE_USER')]\n public function show() { return 1; }\n}\n";
|
||||
|
|
|
|||
|
|
@ -4,12 +4,15 @@
|
|||
//! inherits from `Hanami::Action` (v1 idiom) or includes the
|
||||
//! `Hanami::Action` module (v2 idiom) plus a `call` method that
|
||||
//! receives the request. When the class declaration carries a
|
||||
//! sibling `# nyx-route:` comment line the adapter pulls the path
|
||||
//! template from it; otherwise the binding falls back to
|
||||
//! `/{snake_case(class)}` so harness emitters still have a usable
|
||||
//! [`super::super::RouteShape`].
|
||||
//! sibling `# nyx-route:` comment line or a project `config/routes.rb`
|
||||
//! entry the adapter pulls the path template from it; otherwise the
|
||||
//! binding falls back to `/{snake_case(class)}` so harness emitters
|
||||
//! still have a usable [`super::super::RouteShape`].
|
||||
|
||||
use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape};
|
||||
use crate::dynamic::framework::{
|
||||
FrameworkAdapter, FrameworkBinding, FrameworkDetectionContext, HttpMethod, ProjectFileIndex,
|
||||
RouteShape,
|
||||
};
|
||||
use crate::evidence::EntryKind;
|
||||
use crate::summary::FuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
|
|
@ -33,19 +36,23 @@ fn class_is_hanami_action(class: Node<'_>, bytes: &[u8]) -> bool {
|
|||
/// Resolve the route metadata for `class_name`. Tries the inline
|
||||
/// Hanami v2 routes DSL first (`get "/run", to: "RunAction"` inside a
|
||||
/// `Hanami::Routes` / `routes do` block that co-exists with the
|
||||
/// action class in the same file), then the synthetic
|
||||
/// `# nyx-route: <METHOD> <path>` comment fixtures rely on, then
|
||||
/// finally a `(GET, fallback_path)` default.
|
||||
///
|
||||
/// Cross-file routes resolution (`config/routes.rb` + `app/actions/<slug>/<verb>.rb`)
|
||||
/// still needs a project-level file index on the adapter trait —
|
||||
/// `FrameworkAdapter::detect` only sees one file at a time.
|
||||
/// action class in the same file), then project `config/routes.rb`,
|
||||
/// then the synthetic `# nyx-route: <METHOD> <path>` comment fixtures
|
||||
/// rely on, then finally a `(GET, fallback_path)` default.
|
||||
fn route_for_class(
|
||||
file_bytes: &[u8],
|
||||
class_name: &str,
|
||||
fallback_path: &str,
|
||||
entry_file: &str,
|
||||
project_files: Option<&ProjectFileIndex>,
|
||||
) -> (HttpMethod, String) {
|
||||
if let Some(found) = parse_inline_route(file_bytes, class_name) {
|
||||
let targets = route_targets(class_name, entry_file);
|
||||
if let Some(found) = parse_route_source(file_bytes, &targets) {
|
||||
return found;
|
||||
}
|
||||
if let Some(routes) = project_files.and_then(|files| files.get("config/routes.rb"))
|
||||
&& let Some(found) = parse_route_source(routes, &targets)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
if let Some(found) = pinned_comment_route(file_bytes) {
|
||||
|
|
@ -70,29 +77,22 @@ fn pinned_comment_route(file_bytes: &[u8]) -> Option<(HttpMethod, String)> {
|
|||
None
|
||||
}
|
||||
|
||||
/// Parse the Hanami v2 routes DSL when the routes file and the action
|
||||
/// class co-exist in one file. Recognises lines of the form
|
||||
/// Parse the Hanami v2 routes DSL. Recognises lines of the form
|
||||
/// `<verb> "<path>", to: "<target>"` (or single-quoted variants) and
|
||||
/// matches `<target>` against `class_name` either by exact match or by
|
||||
/// its `snake_case` form (Hanami's container-key convention,
|
||||
/// e.g. `to: "actions.run_action"`).
|
||||
fn parse_inline_route(file_bytes: &[u8], class_name: &str) -> Option<(HttpMethod, String)> {
|
||||
/// matches `<target>` against the class name, its `snake_case` form,
|
||||
/// or the `app/actions/<slug>/<verb>.rb` container key when present.
|
||||
fn parse_route_source(file_bytes: &[u8], targets: &[String]) -> Option<(HttpMethod, String)> {
|
||||
let text = std::str::from_utf8(file_bytes).ok()?;
|
||||
let snake = camel_to_snake(class_name);
|
||||
for raw_line in text.lines() {
|
||||
let line = raw_line.trim_start();
|
||||
if let Some(parsed) = parse_route_line(line, class_name, &snake) {
|
||||
if let Some(parsed) = parse_route_line(line, targets) {
|
||||
return Some(parsed);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn parse_route_line(
|
||||
line: &str,
|
||||
class_orig: &str,
|
||||
class_snake: &str,
|
||||
) -> Option<(HttpMethod, String)> {
|
||||
fn parse_route_line(line: &str, targets: &[String]) -> Option<(HttpMethod, String)> {
|
||||
let (verb_tok, after) = line.split_once(char::is_whitespace)?;
|
||||
let method = HttpMethod::from_ident(verb_tok)?;
|
||||
let after = after.trim_start();
|
||||
|
|
@ -100,13 +100,60 @@ fn parse_route_line(
|
|||
let to_idx = rest.find("to:")?;
|
||||
let after_to = rest[to_idx + 3..].trim_start();
|
||||
let (target, _) = parse_quoted(after_to)?;
|
||||
let target_last = target.rsplit_once('.').map(|(_, s)| s).unwrap_or(&target);
|
||||
if target_last == class_orig || target_last == class_snake {
|
||||
if target_matches(&target, targets) {
|
||||
return Some((method, path));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn route_targets(class_name: &str, entry_file: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
push_unique(&mut out, class_name.to_owned());
|
||||
push_unique(&mut out, camel_to_snake(class_name));
|
||||
if class_name.contains("::") {
|
||||
let dotted = class_name.replace("::", ".");
|
||||
push_unique(&mut out, dotted.clone());
|
||||
let snake_dotted = dotted
|
||||
.split('.')
|
||||
.map(camel_to_snake)
|
||||
.collect::<Vec<_>>()
|
||||
.join(".");
|
||||
push_unique(&mut out, snake_dotted);
|
||||
}
|
||||
if let Some(key) = hanami_action_key_from_path(entry_file) {
|
||||
push_unique(&mut out, key);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn push_unique(out: &mut Vec<String>, value: String) {
|
||||
if !value.is_empty() && !out.iter().any(|existing| existing == &value) {
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn hanami_action_key_from_path(entry_file: &str) -> Option<String> {
|
||||
let normalized = entry_file.replace('\\', "/");
|
||||
let marker = "app/actions/";
|
||||
let rel = normalized
|
||||
.split_once(marker)
|
||||
.map(|(_, rest)| rest)
|
||||
.or_else(|| normalized.strip_prefix(marker))?;
|
||||
let stem = rel.strip_suffix(".rb").unwrap_or(rel);
|
||||
if stem.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(stem.replace('/', "."))
|
||||
}
|
||||
|
||||
fn target_matches(target: &str, candidates: &[String]) -> bool {
|
||||
let normalized = target.replace("::", ".");
|
||||
let target_last = normalized.rsplit('.').next().unwrap_or(normalized.as_str());
|
||||
candidates.iter().any(|candidate| {
|
||||
normalized == *candidate || target_last == candidate || normalized.ends_with(candidate)
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_quoted(s: &str) -> Option<(String, &str)> {
|
||||
let quote = match s.as_bytes().first() {
|
||||
Some(b'"') => '"',
|
||||
|
|
@ -164,31 +211,56 @@ impl FrameworkAdapter for RubyHanamiAdapter {
|
|||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
if summary.name != "call" {
|
||||
return None;
|
||||
}
|
||||
if !source_imports_hanami(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?;
|
||||
if !class_is_hanami_action(class, file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let cls_name = class_name(class, file_bytes).unwrap_or("Entry");
|
||||
let default = hanami_default_path(cls_name);
|
||||
let (http_method, path) = route_for_class(file_bytes, cls_name, &default);
|
||||
let formals = method_formal_names(method, file_bytes);
|
||||
let request_params = bind_path_params(&formals, &path);
|
||||
let middleware = collect_ruby_middleware(ast, file_bytes);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
})
|
||||
detect_hanami(summary, ast, file_bytes, None)
|
||||
}
|
||||
|
||||
fn detect_with_project_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
context: FrameworkDetectionContext<'_>,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
detect_hanami(summary, ast, file_bytes, Some(context.project_files))
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_hanami(
|
||||
summary: &FuncSummary,
|
||||
ast: Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
project_files: Option<&ProjectFileIndex>,
|
||||
) -> Option<FrameworkBinding> {
|
||||
if summary.name != "call" {
|
||||
return None;
|
||||
}
|
||||
if !source_imports_hanami(file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?;
|
||||
if !class_is_hanami_action(class, file_bytes) {
|
||||
return None;
|
||||
}
|
||||
let cls_name = class_name(class, file_bytes).unwrap_or("Entry");
|
||||
let default = hanami_default_path(cls_name);
|
||||
let (http_method, path) = route_for_class(
|
||||
file_bytes,
|
||||
cls_name,
|
||||
&default,
|
||||
&summary.file_path,
|
||||
project_files,
|
||||
);
|
||||
let formals = method_formal_names(method, file_bytes);
|
||||
let request_params = bind_path_params(&formals, &path);
|
||||
let middleware = collect_ruby_middleware(ast, file_bytes);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
route: Some(RouteShape::single(http_method, path)),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -211,6 +283,15 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn summary_at(name: &str, file_path: &str) -> FuncSummary {
|
||||
FuncSummary {
|
||||
name: name.into(),
|
||||
file_path: file_path.into(),
|
||||
lang: "ruby".into(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fires_on_hanami_action_subclass() {
|
||||
let src: &[u8] =
|
||||
|
|
@ -250,6 +331,33 @@ mod tests {
|
|||
assert_eq!(route.path, "/save");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_cross_file_config_routes() {
|
||||
let src: &[u8] =
|
||||
b"require 'hanami/action'\nmodule Books\n class Show\n include Hanami::Action\n def call(req)\n 'ok'\n end\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert(
|
||||
"config/routes.rb",
|
||||
b"Hanami.app.routes do\n get '/books/:id', to: 'books.show'\nend\n".to_vec(),
|
||||
);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding = RubyHanamiAdapter
|
||||
.detect_with_project_context(
|
||||
&summary_at("call", "/tmp/shop/app/actions/books/show.rb"),
|
||||
context,
|
||||
tree.root_node(),
|
||||
src,
|
||||
)
|
||||
.expect("binding from config/routes.rb");
|
||||
let route = binding.route.unwrap();
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/books/:id");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn binds_path_placeholder() {
|
||||
let src: &[u8] = b"# nyx-route: GET /u/:id\nrequire 'hanami/action'\nclass Show < Hanami::Action\n def call(req, id)\n id\n end\nend\n";
|
||||
|
|
|
|||
|
|
@ -23,6 +23,71 @@ use crate::summary::FuncSummary;
|
|||
use crate::summary::ssa_summary::SsaFuncSummary;
|
||||
use crate::symbol::Lang;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::Path;
|
||||
|
||||
/// Small project-file index exposed to framework adapters that need
|
||||
/// config files outside the entry source.
|
||||
///
|
||||
/// Keys are project-relative paths using `/` separators, for example
|
||||
/// `config/routes.rb` or `routes/web.php`. Values are raw file bytes.
|
||||
/// The index is intentionally narrow: callers decide which config
|
||||
/// files to load so adapter dispatch does not walk the whole project.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct ProjectFileIndex {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
}
|
||||
|
||||
impl ProjectFileIndex {
|
||||
/// Create an empty file index.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Build an index from a project root and a fixed list of
|
||||
/// project-relative paths. Missing or unreadable files are skipped.
|
||||
pub fn from_root(root: &Path, rel_paths: &[&str]) -> Self {
|
||||
let mut index = Self::new();
|
||||
for rel in rel_paths {
|
||||
let path = root.join(rel);
|
||||
if let Ok(bytes) = std::fs::read(&path) {
|
||||
index.insert(*rel, bytes);
|
||||
}
|
||||
}
|
||||
index
|
||||
}
|
||||
|
||||
/// Insert or replace a project-relative file.
|
||||
pub fn insert(&mut self, rel_path: impl Into<String>, bytes: impl Into<Vec<u8>>) {
|
||||
self.files
|
||||
.insert(normalize_project_rel(rel_path), bytes.into());
|
||||
}
|
||||
|
||||
/// Return bytes for `rel_path` when present.
|
||||
pub fn get(&self, rel_path: &str) -> Option<&[u8]> {
|
||||
self.files
|
||||
.get(&normalize_project_rel(rel_path))
|
||||
.map(Vec::as_slice)
|
||||
}
|
||||
|
||||
/// True when the index has no files.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.files.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_project_rel(rel_path: impl Into<String>) -> String {
|
||||
rel_path.into().replace('\\', "/")
|
||||
}
|
||||
|
||||
/// Extra context supplied to framework adapters during detection.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct FrameworkDetectionContext<'a> {
|
||||
/// Optional SSA summary for receiver-type-aware narrowing.
|
||||
pub ssa_summary: Option<&'a SsaFuncSummary>,
|
||||
/// Project config files known to the caller.
|
||||
pub project_files: &'a ProjectFileIndex,
|
||||
}
|
||||
|
||||
/// HTTP method recognised by route bindings. Mirrors
|
||||
/// [`crate::entry_points::HttpMethod`] but is re-declared here so the
|
||||
|
|
@ -237,6 +302,21 @@ pub trait FrameworkAdapter: Sync {
|
|||
) -> Option<FrameworkBinding> {
|
||||
self.detect(summary, ast, file_bytes)
|
||||
}
|
||||
|
||||
/// Detection variant with all optional framework context bundled
|
||||
/// into a single struct. Adapters that need project-level route
|
||||
/// files override this method; the default delegates to the
|
||||
/// SSA-aware legacy method so existing adapters keep their current
|
||||
/// behaviour.
|
||||
fn detect_with_project_context(
|
||||
&self,
|
||||
summary: &FuncSummary,
|
||||
context: FrameworkDetectionContext<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
) -> Option<FrameworkBinding> {
|
||||
self.detect_with_context(summary, context.ssa_summary, ast, file_bytes)
|
||||
}
|
||||
}
|
||||
|
||||
/// Walk every adapter registered for `lang` in registration order
|
||||
|
|
@ -265,6 +345,26 @@ pub fn detect_binding_with_context(
|
|||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
lang: Lang,
|
||||
) -> Option<FrameworkBinding> {
|
||||
let project_files = ProjectFileIndex::new();
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary,
|
||||
project_files: &project_files,
|
||||
};
|
||||
detect_binding_with_project_context(summary, context, ast, file_bytes, lang)
|
||||
}
|
||||
|
||||
/// Full-context sibling of [`detect_binding_with_context`].
|
||||
///
|
||||
/// This is the entry point used by spec derivation once it has a
|
||||
/// project root available. Test callers and single-file callers can
|
||||
/// keep using [`detect_binding`] / [`detect_binding_with_context`].
|
||||
pub fn detect_binding_with_project_context(
|
||||
summary: &FuncSummary,
|
||||
context: FrameworkDetectionContext<'_>,
|
||||
ast: tree_sitter::Node<'_>,
|
||||
file_bytes: &[u8],
|
||||
lang: Lang,
|
||||
) -> Option<FrameworkBinding> {
|
||||
for adapter in registry::adapters_for(lang) {
|
||||
debug_assert_eq!(
|
||||
|
|
@ -273,7 +373,9 @@ pub fn detect_binding_with_context(
|
|||
"adapter '{}' registered under wrong lang",
|
||||
adapter.name()
|
||||
);
|
||||
if let Some(binding) = adapter.detect_with_context(summary, ssa_summary, ast, file_bytes) {
|
||||
if let Some(binding) =
|
||||
adapter.detect_with_project_context(summary, context, ast, file_bytes)
|
||||
{
|
||||
return Some(binding);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -718,8 +718,8 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result<HarnessSource, Un
|
|||
/// Phase 19 (Track M.1) — class-method harness for Node.js / TypeScript.
|
||||
///
|
||||
/// Imports the entry module, locates `class` on the exported surface,
|
||||
/// instantiates via the default constructor, falls back to a single
|
||||
/// mock-dependency ctor when the zero-arg path throws, and invokes
|
||||
/// recursively instantiates same-module constructor dependencies up to
|
||||
/// depth 3, falls back to known mock dependencies, and invokes
|
||||
/// `instance[method](payload)`.
|
||||
fn emit_class_method(
|
||||
_spec: &HarnessSpec,
|
||||
|
|
@ -771,23 +771,73 @@ if (typeof _Cls !== 'function') {{
|
|||
process.exit(78);
|
||||
}}
|
||||
|
||||
function _nyxBuildReceiver(Cls) {{
|
||||
try {{
|
||||
return new Cls();
|
||||
}} catch (_e) {{
|
||||
// Fall back to a single mock-dependency ctor. The brief allows
|
||||
// up to depth-3 dependency stubbing; v1 keeps the chain depth
|
||||
// at one and lets the verifier promote precision in a later
|
||||
// phase.
|
||||
try {{ return new Cls(new MockHttpClient(), new MockDatabaseConnection(), new MockLogger()); }} catch (_e2) {{}}
|
||||
try {{ return new Cls(new MockDatabaseConnection()); }} catch (_e3) {{}}
|
||||
try {{ return new Cls(new MockHttpClient()); }} catch (_e4) {{}}
|
||||
try {{ return new Cls(new MockLogger()); }} catch (_e5) {{}}
|
||||
return null;
|
||||
}}
|
||||
function _nyxExportedClass(name) {{
|
||||
if (!name) return null;
|
||||
if (_entry && typeof _entry[name] === 'function') return _entry[name];
|
||||
if (_entry && _entry.default && typeof _entry.default[name] === 'function') return _entry.default[name];
|
||||
if (_entry && typeof _entry.default === 'function' && _entry.default.name === name) return _entry.default;
|
||||
return null;
|
||||
}}
|
||||
|
||||
const _instance = _nyxBuildReceiver(_Cls);
|
||||
function _nyxConstructorParams(Cls) {{
|
||||
let src = '';
|
||||
try {{ src = Function.prototype.toString.call(Cls); }} catch (_e) {{ return []; }}
|
||||
const match = src.match(/constructor\s*\(([^)]*)\)/m);
|
||||
if (!match) return [];
|
||||
return match[1]
|
||||
.split(',')
|
||||
.map((part) => part.replace(/\/\*.*?\*\//g, '').replace(/\/\/.*$/g, '').trim())
|
||||
.map((part) => part.replace(/^(\.\.\.)/, '').split('=')[0].trim())
|
||||
.filter(Boolean);
|
||||
}}
|
||||
|
||||
function _nyxClassNameFromParam(paramName) {{
|
||||
const cleaned = String(paramName || '')
|
||||
.replace(/^[^A-Za-z_$]+/, '')
|
||||
.replace(/[^A-Za-z0-9_$]+(.)/g, (_m, ch) => String(ch).toUpperCase());
|
||||
if (!cleaned) return '';
|
||||
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
|
||||
}}
|
||||
|
||||
function _nyxKnownMock(paramName) {{
|
||||
const lc = String(paramName || '').toLowerCase();
|
||||
if (lc.includes('http') || lc.includes('client')) return new MockHttpClient();
|
||||
if (lc.includes('database') || lc.includes('db')) return new MockDatabaseConnection();
|
||||
if (lc.includes('logger') || lc.includes('log')) return new MockLogger();
|
||||
return null;
|
||||
}}
|
||||
|
||||
function _nyxBuildDependency(paramName, depth, seen) {{
|
||||
const depName = _nyxClassNameFromParam(paramName);
|
||||
const Dep = _nyxExportedClass(depName);
|
||||
if (typeof Dep === 'function') {{
|
||||
const built = _nyxBuildReceiver(Dep, depth - 1, new Set(seen));
|
||||
if (built != null) return built;
|
||||
}}
|
||||
return _nyxKnownMock(paramName);
|
||||
}}
|
||||
|
||||
function _nyxBuildReceiver(Cls, depth = 3, seen = new Set()) {{
|
||||
if (typeof Cls !== 'function') return null;
|
||||
const clsName = Cls.name || '<anonymous>';
|
||||
if (depth < 0 || seen.has(clsName)) return null;
|
||||
seen.add(clsName);
|
||||
const params = _nyxConstructorParams(Cls);
|
||||
if (params.length > 0) {{
|
||||
const deps = params.map((name) => _nyxBuildDependency(name, depth, seen));
|
||||
if (deps.every((dep) => dep != null)) {{
|
||||
try {{ return new Cls(...deps); }} catch (_e) {{}}
|
||||
}}
|
||||
}}
|
||||
try {{ return new Cls(); }} catch (_e) {{}}
|
||||
try {{ return new Cls(new MockHttpClient(), new MockDatabaseConnection(), new MockLogger()); }} catch (_e2) {{}}
|
||||
try {{ return new Cls(new MockDatabaseConnection()); }} catch (_e3) {{}}
|
||||
try {{ return new Cls(new MockHttpClient()); }} catch (_e4) {{}}
|
||||
try {{ return new Cls(new MockLogger()); }} catch (_e5) {{}}
|
||||
return null;
|
||||
}}
|
||||
|
||||
const _instance = _nyxBuildReceiver(_Cls, 3);
|
||||
if (_instance == null) {{
|
||||
process.stderr.write('NYX_CLASS_CTOR_FAILED: ' + {class:?} + '\n');
|
||||
process.exit(78);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
use crate::callgraph::{CallGraph, CallGraphAnalysis};
|
||||
use crate::commands::scan::Diag;
|
||||
use crate::dynamic::corpus::CORPUS_VERSION;
|
||||
use crate::dynamic::framework::FrameworkBinding;
|
||||
use crate::dynamic::framework::{FrameworkBinding, FrameworkDetectionContext, ProjectFileIndex};
|
||||
use crate::dynamic::stubs::StubKind;
|
||||
use crate::evidence::{Confidence, FlowStepKind, UnsupportedReason};
|
||||
use crate::labels::Cap;
|
||||
|
|
@ -28,7 +28,7 @@ use crate::summary::{FuncSummary, GlobalSummaries};
|
|||
use crate::symbol::{FuncKey, Lang};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashSet, VecDeque};
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Re-export of the always-present [`crate::evidence::SpecDerivationStrategy`].
|
||||
///
|
||||
|
|
@ -1241,9 +1241,14 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum
|
|||
let summary_ref = resolved.unwrap_or(&synthetic);
|
||||
let ssa_ref = summaries
|
||||
.and_then(|gs| find_ssa_summary_by_path(gs, spec.lang, &spec.entry_name, &spec.entry_file));
|
||||
if let Some(binding) = crate::dynamic::framework::detect_binding_with_context(
|
||||
let project_files = framework_project_files_for_entry(&spec.entry_file, spec.lang);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: ssa_ref,
|
||||
project_files: &project_files,
|
||||
};
|
||||
if let Some(binding) = crate::dynamic::framework::detect_binding_with_project_context(
|
||||
summary_ref,
|
||||
ssa_ref,
|
||||
context,
|
||||
tree.root_node(),
|
||||
&bytes,
|
||||
spec.lang,
|
||||
|
|
@ -1252,6 +1257,43 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum
|
|||
}
|
||||
}
|
||||
|
||||
fn framework_project_files_for_entry(entry_file: &str, lang: Lang) -> ProjectFileIndex {
|
||||
let Some(root) = infer_framework_project_root(Path::new(entry_file), lang) else {
|
||||
return ProjectFileIndex::new();
|
||||
};
|
||||
let rel_paths: &[&str] = match lang {
|
||||
Lang::Ruby => &["config/routes.rb"],
|
||||
Lang::Php => &[
|
||||
"config/routes.yaml",
|
||||
"config/routes.yml",
|
||||
"routes/web.php",
|
||||
"routes/api.php",
|
||||
"app/Config/Routes.php",
|
||||
],
|
||||
_ => &[],
|
||||
};
|
||||
ProjectFileIndex::from_root(&root, rel_paths)
|
||||
}
|
||||
|
||||
fn infer_framework_project_root(entry_path: &Path, lang: Lang) -> Option<PathBuf> {
|
||||
let dirs: &[&str] = match lang {
|
||||
Lang::Ruby => &["app"],
|
||||
Lang::Php => &["src", "app"],
|
||||
_ => &[],
|
||||
};
|
||||
for ancestor in entry_path.ancestors() {
|
||||
let Some(name) = ancestor.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if dirs.contains(&name)
|
||||
&& let Some(parent) = ancestor.parent()
|
||||
{
|
||||
return Some(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
entry_path.parent().map(|p| p.to_path_buf())
|
||||
}
|
||||
|
||||
/// Phase 18 (Track M.0) — apply a resolved [`FrameworkBinding`] onto
|
||||
/// the spec. Carved out of [`attach_framework_binding`] so the
|
||||
/// stamping branch (Phase 18 data-bearing-variant propagation +
|
||||
|
|
@ -2227,6 +2269,53 @@ mod tests {
|
|||
assert_eq!(spec_no_summaries.spec_hash, spec_with_summaries.spec_hash);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attach_framework_binding_reads_project_route_config() {
|
||||
use crate::dynamic::framework::HttpMethod;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let action_dir = dir.path().join("app/actions/books");
|
||||
fs::create_dir_all(&action_dir).expect("action dir");
|
||||
let config_dir = dir.path().join("config");
|
||||
fs::create_dir_all(&config_dir).expect("config dir");
|
||||
let action = action_dir.join("show.rb");
|
||||
fs::File::create(&action)
|
||||
.expect("action create")
|
||||
.write_all(
|
||||
b"require 'hanami/action'\nmodule Books\n class Show\n include Hanami::Action\n def call(req)\n system(req.params[:cmd])\n end\n end\nend\n",
|
||||
)
|
||||
.expect("action write");
|
||||
fs::File::create(config_dir.join("routes.rb"))
|
||||
.expect("routes create")
|
||||
.write_all(b"Hanami.app.routes do\n post '/books/:id', to: 'books.show'\nend\n")
|
||||
.expect("routes write");
|
||||
let entry_file = action.to_string_lossy().into_owned();
|
||||
|
||||
let ev = Evidence {
|
||||
flow_steps: vec![source_step(&entry_file, "call"), sink_step(&entry_file)],
|
||||
sink_caps: Cap::SHELL_ESCAPE.bits(),
|
||||
..Default::default()
|
||||
};
|
||||
let diag = crate::commands::scan::Diag {
|
||||
id: "rb.cmdi.system".into(),
|
||||
path: entry_file.clone(),
|
||||
line: 5,
|
||||
confidence: Some(Confidence::High),
|
||||
evidence: Some(ev),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let spec = HarnessSpec::from_finding_full(&diag, false, None, None)
|
||||
.expect("spec derives and attaches framework config");
|
||||
let binding = spec.framework.expect("hanami binding");
|
||||
assert_eq!(binding.adapter, "ruby-hanami");
|
||||
let route = binding.route.expect("route");
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
assert_eq!(route.path, "/books/:id");
|
||||
}
|
||||
|
||||
/// Phase 18 (Track M.0) deferred-fix: when a [`FrameworkBinding`]
|
||||
/// carries one of the seven data-bearing variants
|
||||
/// (`ClassMethod`, `MessageHandler`, …), the spec stamping path
|
||||
|
|
|
|||
|
|
@ -166,6 +166,16 @@ fn class_method_python_dispatch_reads_payload_and_invokes_method() {
|
|||
assert!(h.source.contains("_nyx_resolve_annotation"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_method_js_dispatch_builds_recursive_receiver() {
|
||||
let spec = make_spec(Lang::JavaScript);
|
||||
let h = lang::emit(&spec).expect("emit ok");
|
||||
assert!(h.source.contains("_nyxBuildReceiver(_Cls, 3)"));
|
||||
assert!(h.source.contains("_nyxConstructorParams"));
|
||||
assert!(h.source.contains("_nyxExportedClass"));
|
||||
assert!(h.source.contains("depth = 3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn class_method_java_emits_reflective_dispatch() {
|
||||
let spec = make_spec(Lang::Java);
|
||||
|
|
@ -285,6 +295,17 @@ mod e2e_phase_19 {
|
|||
cap: Cap::CODE_EXEC,
|
||||
bins: &["node"],
|
||||
},
|
||||
Case {
|
||||
lang: Lang::JavaScript,
|
||||
fixture_dir: "javascript_recursive_deps",
|
||||
vuln_file: "vuln.js",
|
||||
benign_file: "benign.js",
|
||||
vuln_class: "UserService",
|
||||
benign_class: "UserService",
|
||||
method: "run",
|
||||
cap: Cap::CODE_EXEC,
|
||||
bins: &["node"],
|
||||
},
|
||||
Case {
|
||||
lang: Lang::TypeScript,
|
||||
fixture_dir: "typescript",
|
||||
|
|
@ -296,6 +317,17 @@ mod e2e_phase_19 {
|
|||
cap: Cap::CODE_EXEC,
|
||||
bins: &["node"],
|
||||
},
|
||||
Case {
|
||||
lang: Lang::TypeScript,
|
||||
fixture_dir: "typescript_recursive_deps",
|
||||
vuln_file: "vuln.ts",
|
||||
benign_file: "benign.ts",
|
||||
vuln_class: "UserService",
|
||||
benign_class: "UserService",
|
||||
method: "run",
|
||||
cap: Cap::CODE_EXEC,
|
||||
bins: &["node"],
|
||||
},
|
||||
Case {
|
||||
lang: Lang::Php,
|
||||
fixture_dir: "php",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
'use strict';
|
||||
|
||||
class ShellRunner {
|
||||
run(_command) {
|
||||
return 'safe';
|
||||
}
|
||||
}
|
||||
|
||||
class UserRepository {
|
||||
constructor(shellRunner) {
|
||||
this.shellRunner = shellRunner;
|
||||
}
|
||||
|
||||
find(input) {
|
||||
return this.shellRunner.run(input);
|
||||
}
|
||||
}
|
||||
|
||||
class UserService {
|
||||
constructor(userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
run(input) {
|
||||
return this.userRepository.find(input);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UserService, UserRepository, ShellRunner };
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
'use strict';
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
class ShellRunner {
|
||||
run(command) {
|
||||
return execSync('true ' + command).toString();
|
||||
}
|
||||
}
|
||||
|
||||
class UserRepository {
|
||||
constructor(shellRunner) {
|
||||
this.shellRunner = shellRunner;
|
||||
}
|
||||
|
||||
find(input) {
|
||||
return this.shellRunner.run(input);
|
||||
}
|
||||
}
|
||||
|
||||
class UserService {
|
||||
constructor(userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
run(input) {
|
||||
return this.userRepository.find(input);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UserService, UserRepository, ShellRunner };
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
'use strict';
|
||||
|
||||
class ShellRunner {
|
||||
run(_command) {
|
||||
return 'safe';
|
||||
}
|
||||
}
|
||||
|
||||
class UserRepository {
|
||||
constructor(shellRunner) {
|
||||
this.shellRunner = shellRunner;
|
||||
}
|
||||
|
||||
find(input) {
|
||||
return this.shellRunner.run(input);
|
||||
}
|
||||
}
|
||||
|
||||
class UserService {
|
||||
constructor(userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
run(input) {
|
||||
return this.userRepository.find(input);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UserService, UserRepository, ShellRunner };
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
'use strict';
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
class ShellRunner {
|
||||
run(command) {
|
||||
return execSync('true ' + command).toString();
|
||||
}
|
||||
}
|
||||
|
||||
class UserRepository {
|
||||
constructor(shellRunner) {
|
||||
this.shellRunner = shellRunner;
|
||||
}
|
||||
|
||||
find(input) {
|
||||
return this.shellRunner.run(input);
|
||||
}
|
||||
}
|
||||
|
||||
class UserService {
|
||||
constructor(userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
run(input) {
|
||||
return this.userRepository.find(input);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { UserService, UserRepository, ShellRunner };
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?php
|
||||
use CodeIgniter\Router\RouteCollection;
|
||||
|
||||
$routes->get('users/(:num)', 'UserController::show');
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
namespace App\Controllers;
|
||||
|
||||
class UserController extends BaseController
|
||||
{
|
||||
public function show($num)
|
||||
{
|
||||
return $num;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
<?php
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
class UserController
|
||||
{
|
||||
public function show($id)
|
||||
{
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?php
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\UserController;
|
||||
|
||||
Route::get('/users/{id}', [UserController::class, 'show'])->middleware('auth');
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
report_show:
|
||||
path: /reports/{id}
|
||||
controller: App\Controller\ReportController::show
|
||||
methods: [POST]
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
|
||||
class ReportController extends AbstractController
|
||||
{
|
||||
public function show($id)
|
||||
{
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
require "hanami/action"
|
||||
|
||||
module Books
|
||||
class Show
|
||||
include Hanami::Action
|
||||
|
||||
def call(req)
|
||||
req.params[:id]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Hanami.app.routes do
|
||||
get "/books/:id", to: "books.show"
|
||||
end
|
||||
|
|
@ -13,7 +13,10 @@
|
|||
|
||||
mod common;
|
||||
|
||||
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
|
||||
use nyx_scanner::dynamic::framework::{
|
||||
FrameworkDetectionContext, HttpMethod, ParamSource, ProjectFileIndex, detect_binding,
|
||||
detect_binding_with_project_context,
|
||||
};
|
||||
use nyx_scanner::evidence::EntryKind;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
|
@ -115,6 +118,30 @@ fn symfony_benign_fixture_binds_same_route_shape() {
|
|||
assert_eq!(route.path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symfony_yaml_fixture_binds_cross_file_route() {
|
||||
let path =
|
||||
"tests/dynamic_fixtures/php_frameworks/symfony_yaml/src/Controller/ReportController.php";
|
||||
let routes = "tests/dynamic_fixtures/php_frameworks/symfony_yaml/config/routes.yaml";
|
||||
let bytes = std::fs::read(path).expect("symfony yaml controller fixture exists");
|
||||
let route_bytes = std::fs::read(routes).expect("symfony yaml routes fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("show", path);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert("config/routes.yaml", route_bytes);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding =
|
||||
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("symfony adapter must bind through config/routes.yaml");
|
||||
assert_eq!(binding.adapter, "php-symfony");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/reports/{id}");
|
||||
assert_eq!(route.method, HttpMethod::POST);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codeigniter_vuln_fixture_binds_route() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter/vuln.php";
|
||||
|
|
@ -143,6 +170,57 @@ fn codeigniter_benign_fixture_binds_same_route_shape() {
|
|||
assert_eq!(route.path, "run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_routes_fixture_binds_cross_file_route() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/laravel_routes/app/Http/Controllers/UserController.php";
|
||||
let routes = "tests/dynamic_fixtures/php_frameworks/laravel_routes/routes/web.php";
|
||||
let bytes = std::fs::read(path).expect("laravel controller fixture exists");
|
||||
let route_bytes = std::fs::read(routes).expect("laravel routes fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("show", path);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert("routes/web.php", route_bytes);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding =
|
||||
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("laravel adapter must bind through routes/web.php");
|
||||
assert_eq!(binding.adapter, "php-laravel");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "/users/{id}");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert!(
|
||||
binding.middleware.iter().any(|m| m.name == "auth"),
|
||||
"expected auth middleware from route config, got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codeigniter_config_fixture_binds_cross_file_route() {
|
||||
let path = "tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Controllers/UserController.php";
|
||||
let routes = "tests/dynamic_fixtures/php_frameworks/codeigniter_config/app/Config/Routes.php";
|
||||
let bytes = std::fs::read(path).expect("codeigniter controller fixture exists");
|
||||
let route_bytes = std::fs::read(routes).expect("codeigniter routes fixture exists");
|
||||
let tree = parse_php(&bytes);
|
||||
let summary = summary_for("show", path);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert("app/Config/Routes.php", route_bytes);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding =
|
||||
detect_binding_with_project_context(&summary, context, tree.root_node(), &bytes, Lang::Php)
|
||||
.expect("codeigniter adapter must bind through app/Config/Routes.php");
|
||||
assert_eq!(binding.adapter, "php-codeigniter");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.path, "users/(:num)");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn laravel_adapter_ignores_helper_method() {
|
||||
// `helper` is declared but not referenced in any `Route::*` call.
|
||||
|
|
|
|||
|
|
@ -11,7 +11,10 @@
|
|||
|
||||
#![cfg(feature = "dynamic")]
|
||||
|
||||
use nyx_scanner::dynamic::framework::{HttpMethod, ParamSource, detect_binding};
|
||||
use nyx_scanner::dynamic::framework::{
|
||||
FrameworkDetectionContext, HttpMethod, ParamSource, ProjectFileIndex, detect_binding,
|
||||
detect_binding_with_project_context,
|
||||
};
|
||||
use nyx_scanner::evidence::EntryKind;
|
||||
use nyx_scanner::summary::FuncSummary;
|
||||
use nyx_scanner::symbol::Lang;
|
||||
|
|
@ -147,6 +150,34 @@ fn hanami_benign_fixture_binds_same_route_shape() {
|
|||
assert_eq!(route.path, "/run");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hanami_config_routes_fixture_binds_cross_file_route() {
|
||||
let path = "tests/dynamic_fixtures/ruby/hanami_config_routes/app/actions/books/show.rb";
|
||||
let routes = "tests/dynamic_fixtures/ruby/hanami_config_routes/config/routes.rb";
|
||||
let bytes = std::fs::read(path).expect("hanami action fixture exists");
|
||||
let route_bytes = std::fs::read(routes).expect("hanami routes fixture exists");
|
||||
let tree = parse_ruby(&bytes);
|
||||
let summary = summary_for("call", path);
|
||||
let mut project_files = ProjectFileIndex::new();
|
||||
project_files.insert("config/routes.rb", route_bytes);
|
||||
let context = FrameworkDetectionContext {
|
||||
ssa_summary: None,
|
||||
project_files: &project_files,
|
||||
};
|
||||
let binding = detect_binding_with_project_context(
|
||||
&summary,
|
||||
context,
|
||||
tree.root_node(),
|
||||
&bytes,
|
||||
Lang::Ruby,
|
||||
)
|
||||
.expect("hanami adapter must bind through config/routes.rb");
|
||||
assert_eq!(binding.adapter, "ruby-hanami");
|
||||
let route = binding.route.as_ref().expect("route");
|
||||
assert_eq!(route.method, HttpMethod::GET);
|
||||
assert_eq!(route.path, "/books/:id");
|
||||
}
|
||||
|
||||
// ── Cross-adapter disambiguation ─────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue