mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-12 19:55:14 +02:00
[pitboss/grind] deferred session-0003 (20260522T043516Z-29b8)
This commit is contained in:
parent
ebe3a4fca0
commit
987fc1d89f
8 changed files with 539 additions and 19 deletions
|
|
@ -20,8 +20,8 @@ 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,
|
||||
bind_php_path_params, collect_php_middleware, find_codeigniter_route, find_php_function,
|
||||
php_class_name, php_formal_names, source_imports_codeigniter,
|
||||
};
|
||||
|
||||
pub struct PhpCodeIgniterAdapter;
|
||||
|
|
@ -53,6 +53,7 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter {
|
|||
|
||||
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);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -60,7 +61,7 @@ impl FrameworkAdapter for PhpCodeIgniterAdapter {
|
|||
route: Some(RouteShape { method, path }),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,8 +21,8 @@ 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,
|
||||
bind_php_path_params, collect_php_middleware, find_laravel_static_route, find_php_function,
|
||||
php_class_name, php_formal_names, source_imports_laravel,
|
||||
};
|
||||
|
||||
pub struct PhpLaravelAdapter;
|
||||
|
|
@ -54,6 +54,7 @@ impl FrameworkAdapter for PhpLaravelAdapter {
|
|||
|
||||
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);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -61,7 +62,7 @@ impl FrameworkAdapter for PhpLaravelAdapter {
|
|||
route: Some(RouteShape { method, path }),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -138,6 +139,37 @@ mod tests {
|
|||
assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_chained_call() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users/{id}', 'UserController@show')->middleware('auth');\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!(
|
||||
binding.middleware.iter().any(|m| m.name == "auth"),
|
||||
"got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_constructor_call() {
|
||||
let src: &[u8] = b"<?php\nuse Illuminate\\Support\\Facades\\Route;\nRoute::get('/users', 'UserController@index');\nclass UserController {\n public function __construct() { $this->middleware('auth:sanctum'); }\n public function index() { return 1; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let binding = PhpLaravelAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert!(
|
||||
binding
|
||||
.middleware
|
||||
.iter()
|
||||
.any(|m| m.name == "auth:sanctum"),
|
||||
"got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_laravel_not_imported() {
|
||||
let src: &[u8] = b"<?php\nfunction f($x) { return $x; }\n";
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@
|
|||
//! 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 crate::dynamic::framework::{
|
||||
HttpMethod, MiddlewareShape, ParamBinding, ParamSource, auth_markers,
|
||||
};
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// True when `bytes` carries any of the well-known Laravel import
|
||||
|
|
@ -661,6 +664,117 @@ fn codeigniter_callable_matches(
|
|||
}
|
||||
}
|
||||
|
||||
/// Walk every PHP attach-site in `root` and collect arguments whose
|
||||
/// names match a known PHP middleware marker (see
|
||||
/// [`crate::dynamic::framework::auth_markers::is_protective`]).
|
||||
///
|
||||
/// Three attach idioms are recognised:
|
||||
///
|
||||
/// - **Chained `->middleware(...)` member calls** (Laravel):
|
||||
/// `Route::get('/x', '...')->middleware('auth:sanctum')`,
|
||||
/// `$this->middleware(['auth', 'verified'])` declared in a
|
||||
/// controller constructor.
|
||||
/// - **Static `Route::middleware(...)` scoped calls** (Laravel):
|
||||
/// `Route::middleware(['auth'])->group(...)`.
|
||||
/// - **Symfony PHP attributes** on `class_declaration` /
|
||||
/// `method_declaration` / `function_definition`: `#[IsGranted]`,
|
||||
/// `#[Security]`. Attribute leaf names are wrapped with the
|
||||
/// `#[...]` brackets so they classify against the PHP marker
|
||||
/// table (`#[IsGranted]`, `#[Security]`).
|
||||
///
|
||||
/// Argument rendering (for `->middleware(...)` / `Route::middleware(...)`):
|
||||
/// - string literal → string content (e.g. `'auth:sanctum'`)
|
||||
/// - array literal → each element string content, in order
|
||||
/// - non-string args dropped silently
|
||||
///
|
||||
/// De-duplicates within a single file; preserves declaration order.
|
||||
/// Names the registry does not recognise are dropped silently —
|
||||
/// callers can re-walk with a wider predicate if broader inclusion is
|
||||
/// needed. CodeIgniter `['filter' => 'auth-jwt']` array-key idiom is
|
||||
/// out of scope for v1; revisit when a real-world CodeIgniter fixture
|
||||
/// surfaces the gap.
|
||||
pub fn collect_php_middleware(root: Node<'_>, bytes: &[u8]) -> Vec<MiddlewareShape> {
|
||||
let mut raw: Vec<String> = Vec::new();
|
||||
walk_php_middleware(root, bytes, &mut raw);
|
||||
let mut out: Vec<MiddlewareShape> = Vec::new();
|
||||
for name in raw {
|
||||
if auth_markers::is_protective(Lang::Php, &name)
|
||||
&& !out.iter().any(|m| m.name == name)
|
||||
{
|
||||
out.push(MiddlewareShape { name });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_php_middleware(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
|
||||
match node.kind() {
|
||||
"member_call_expression" | "scoped_call_expression" => {
|
||||
collect_middleware_call(node, bytes, out);
|
||||
}
|
||||
"class_declaration" | "method_declaration" | "function_definition" => {
|
||||
iter_php_attributes(node, bytes, |_ann, leaf| {
|
||||
out.push(format!("#[{leaf}]"));
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_php_middleware(child, bytes, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_middleware_call(call: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
|
||||
let Some(name_node) = call.child_by_field_name("name") else {
|
||||
return;
|
||||
};
|
||||
let Ok(name) = name_node.utf8_text(bytes) else {
|
||||
return;
|
||||
};
|
||||
if name != "middleware" {
|
||||
return;
|
||||
}
|
||||
let Some(args) = call.child_by_field_name("arguments") else {
|
||||
return;
|
||||
};
|
||||
let mut ac = args.walk();
|
||||
for arg in args.named_children(&mut ac) {
|
||||
if arg.kind() != "argument" {
|
||||
continue;
|
||||
}
|
||||
if arg.child_by_field_name("name").is_some() {
|
||||
continue;
|
||||
}
|
||||
let Some(value) = arg.named_child(0) else {
|
||||
continue;
|
||||
};
|
||||
push_middleware_value(value, bytes, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_middleware_value(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
|
||||
match node.kind() {
|
||||
"string" | "encapsed_string" => {
|
||||
if let Some(s) = string_content(node, bytes) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
"array_creation_expression" => {
|
||||
let mut ac = node.walk();
|
||||
for elem in node.named_children(&mut ac) {
|
||||
if elem.kind() != "array_element_initializer" {
|
||||
continue;
|
||||
}
|
||||
if let Some(value) = elem.named_child(0) {
|
||||
push_middleware_value(value, bytes, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -792,4 +906,81 @@ mod tests {
|
|||
assert_eq!(hit.0, HttpMethod::GET);
|
||||
assert_eq!(hit.1, "users/(:num)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_chained_middleware_string_arg() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nRoute::get('/users', 'UserController@index')->middleware('auth');\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "auth"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_chained_middleware_with_sanctum_guard() {
|
||||
let src: &[u8] = b"<?php\nRoute::get('/x', 'C@x')->middleware('auth:sanctum');\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "auth:sanctum"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_array_middleware_arg() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nRoute::get('/x', 'C@x')->middleware(['auth', 'verified']);\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "auth"), "got {mw:?}");
|
||||
assert!(mw.iter().any(|m| m.name == "verified"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_static_route_middleware_chain() {
|
||||
let src: &[u8] = b"<?php\nRoute::middleware(['auth'])->group(function () {});\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "auth"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_controller_constructor_middleware() {
|
||||
let src: &[u8] = b"<?php\nclass C {\n public function __construct() {\n $this->middleware('auth');\n }\n}\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "auth"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_symfony_is_granted_attribute() {
|
||||
let src: &[u8] = b"<?php\nclass C {\n #[IsGranted('ROLE_USER')]\n public function show($id) { return $id; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "#[IsGranted]"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_symfony_security_attribute_at_class_level() {
|
||||
let src: &[u8] = b"<?php\n#[Security(\"is_granted('ROLE_ADMIN')\")]\nclass C {\n public function show() { return 1; }\n}\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "#[Security]"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_unknown_php_middleware_names() {
|
||||
let src: &[u8] =
|
||||
b"<?php\nRoute::get('/x', 'C@x')->middleware('custom-thing-not-in-table');\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
assert!(mw.is_empty(), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_repeated_php_middleware() {
|
||||
let src: &[u8] = b"<?php\nRoute::get('/a', 'C@a')->middleware('auth');\nRoute::get('/b', 'C@b')->middleware('auth');\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_php_middleware(tree.root_node(), src);
|
||||
let auth_count = mw.iter().filter(|m| m.name == "auth").count();
|
||||
assert_eq!(auth_count, 1, "got {mw:?}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ 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,
|
||||
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,
|
||||
};
|
||||
|
||||
pub struct PhpSymfonyAdapter;
|
||||
|
|
@ -84,6 +84,7 @@ impl FrameworkAdapter for PhpSymfonyAdapter {
|
|||
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);
|
||||
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
|
|
@ -94,7 +95,7 @@ impl FrameworkAdapter for PhpSymfonyAdapter {
|
|||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -161,6 +162,23 @@ mod tests {
|
|||
assert_eq!(route.path, "/x");
|
||||
}
|
||||
|
||||
#[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";
|
||||
let tree = parse(src);
|
||||
let binding = PhpSymfonyAdapter
|
||||
.detect(&summary("show"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert!(
|
||||
binding
|
||||
.middleware
|
||||
.iter()
|
||||
.any(|m| m.name == "#[IsGranted]"),
|
||||
"got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_when_symfony_not_imported() {
|
||||
let src: &[u8] = b"<?php\n#[Route('/x')]\nfunction f() { return 1; }\n";
|
||||
|
|
|
|||
|
|
@ -16,8 +16,8 @@ use crate::symbol::Lang;
|
|||
use tree_sitter::Node;
|
||||
|
||||
use super::ruby_routes::{
|
||||
bind_path_params, class_extends, class_includes, class_name, find_class_with_method,
|
||||
method_formal_names, source_imports_hanami,
|
||||
bind_path_params, class_extends, class_includes, class_name, collect_ruby_middleware,
|
||||
find_class_with_method, method_formal_names, source_imports_hanami,
|
||||
};
|
||||
|
||||
pub struct RubyHanamiAdapter;
|
||||
|
|
@ -172,6 +172,7 @@ impl FrameworkAdapter for RubyHanamiAdapter {
|
|||
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,
|
||||
|
|
@ -181,7 +182,7 @@ impl FrameworkAdapter for RubyHanamiAdapter {
|
|||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -356,6 +357,23 @@ mod tests {
|
|||
assert_eq!(route.path, "/new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_before_action() {
|
||||
let src: &[u8] = b"require 'hanami/action'\nclass Show < Hanami::Action\n before_action :authenticate_user!\n def call(req)\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyHanamiAdapter
|
||||
.detect(&summary("call"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert!(
|
||||
binding
|
||||
.middleware
|
||||
.iter()
|
||||
.any(|m| m.name == "authenticate_user!"),
|
||||
"expected authenticate_user! marker, got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_non_hanami_classes() {
|
||||
let src: &[u8] =
|
||||
|
|
|
|||
|
|
@ -17,8 +17,9 @@ use crate::symbol::Lang;
|
|||
use tree_sitter::Node;
|
||||
|
||||
use super::ruby_routes::{
|
||||
bind_path_params, class_extends, class_name, find_class_with_method, first_string_arg,
|
||||
first_symbol_arg, kwarg_string, method_formal_names, source_imports_rails, verb_from_ident,
|
||||
bind_path_params, class_extends, class_name, collect_ruby_middleware, find_class_with_method,
|
||||
first_string_arg, first_symbol_arg, kwarg_string, method_formal_names, source_imports_rails,
|
||||
verb_from_ident,
|
||||
};
|
||||
|
||||
pub struct RubyRailsAdapter;
|
||||
|
|
@ -283,6 +284,7 @@ impl FrameworkAdapter for RubyRailsAdapter {
|
|||
|
||||
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(),
|
||||
|
|
@ -293,7 +295,7 @@ impl FrameworkAdapter for RubyRailsAdapter {
|
|||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -464,6 +466,34 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_before_action() {
|
||||
let src: &[u8] = b"class UsersController < ApplicationController\n before_action :authenticate_user!\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyRailsAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert_eq!(binding.middleware.len(), 1);
|
||||
assert_eq!(binding.middleware[0].name, "authenticate_user!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_protect_from_forgery() {
|
||||
let src: &[u8] = b"class A < ApplicationController\n protect_from_forgery with: :exception\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubyRailsAdapter
|
||||
.detect(&summary("index"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert!(
|
||||
binding
|
||||
.middleware
|
||||
.iter()
|
||||
.any(|m| m.name == "protect_from_forgery"),
|
||||
"expected protect_from_forgery marker, got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rails_controller_path_drops_suffix_and_snake_cases() {
|
||||
assert_eq!(rails_controller_path("UsersController"), "users");
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@
|
|||
//! 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 crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource, auth_markers};
|
||||
use crate::symbol::Lang;
|
||||
use tree_sitter::Node;
|
||||
|
||||
/// True when `bytes` carries any of the well-known Rails import
|
||||
|
|
@ -448,6 +449,136 @@ pub fn verb_from_ident(ident: &str) -> Option<HttpMethod> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Ruby attach-verb identifiers that introduce a middleware /
|
||||
/// before-filter / output sanitiser declaration. Rails controllers
|
||||
/// use `before_action :authenticate_user!`; Sinatra modular apps use
|
||||
/// `use Rack::Auth::Basic`; both Rails and Hanami v1 also accept
|
||||
/// `before :method_name`. Some verbs (`protect_from_forgery`) act as
|
||||
/// self-naming markers with no positional argument.
|
||||
const RUBY_ATTACH_VERBS: &[&str] = &[
|
||||
"before_action",
|
||||
"prepend_before_action",
|
||||
"skip_before_action",
|
||||
"around_action",
|
||||
"append_before_action",
|
||||
"before",
|
||||
"use",
|
||||
"protect_from_forgery",
|
||||
];
|
||||
|
||||
/// Walk every Ruby `call` node whose identifier matches a known
|
||||
/// middleware-attach verb and collect arguments whose names match a
|
||||
/// known Ruby middleware marker (see
|
||||
/// [`crate::dynamic::framework::auth_markers::is_protective`]).
|
||||
///
|
||||
/// Per-framework attach-verb idioms:
|
||||
/// - Rails: `before_action :authenticate_user!`,
|
||||
/// `protect_from_forgery with: :exception`,
|
||||
/// `prepend_before_action :require_login`
|
||||
/// - Sinatra: `use Rack::Auth::Basic`, `before do ... end`
|
||||
/// - Hanami v1: `before :authenticate_user!`
|
||||
///
|
||||
/// Argument rendering:
|
||||
/// - simple symbol (`:authenticate_user!`) → `"authenticate_user!"`
|
||||
/// - bare identifier (`use AuthMiddleware`) → `"AuthMiddleware"`
|
||||
/// - constant (`use Authenticate`) → `"Authenticate"`
|
||||
/// - scoped constant (`use Rack::Auth::Basic`) → `"Rack::Auth::Basic"`
|
||||
///
|
||||
/// In addition the verb token itself is emitted as a candidate so
|
||||
/// self-naming forms like `protect_from_forgery` (often invoked with
|
||||
/// only kwargs) classify against the Ruby auth-markers table.
|
||||
///
|
||||
/// Recursion stops at `method` / `singleton_method` boundaries so a
|
||||
/// stray `before_action :x` inside an unrelated method body is not
|
||||
/// picked up. De-duplicates within a single file; preserves
|
||||
/// declaration order. Names the registry does not recognise are
|
||||
/// dropped silently — callers can re-walk with a wider predicate if
|
||||
/// broader inclusion is needed.
|
||||
pub fn collect_ruby_middleware(root: Node<'_>, bytes: &[u8]) -> Vec<MiddlewareShape> {
|
||||
let mut raw: Vec<String> = Vec::new();
|
||||
walk_attach_calls(root, bytes, &mut raw);
|
||||
let mut out: Vec<MiddlewareShape> = Vec::new();
|
||||
for name in raw {
|
||||
if auth_markers::is_protective(Lang::Ruby, &name)
|
||||
&& !out.iter().any(|m| m.name == name)
|
||||
{
|
||||
out.push(MiddlewareShape { name });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn walk_attach_calls(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
|
||||
if node.kind() == "call" {
|
||||
try_collect_attach_call(node, bytes, out);
|
||||
}
|
||||
// Middleware declarations live at class body / top level / routes
|
||||
// block scope, not inside per-action method bodies. Skip descent
|
||||
// into method nodes to avoid binding stray `before_action :x` calls
|
||||
// hidden inside a helper method.
|
||||
if matches!(node.kind(), "method" | "singleton_method") {
|
||||
return;
|
||||
}
|
||||
let mut cur = node.walk();
|
||||
for child in node.children(&mut cur) {
|
||||
walk_attach_calls(child, bytes, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn try_collect_attach_call(call: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
|
||||
let mut cur = call.walk();
|
||||
let mut verb: Option<&str> = None;
|
||||
let mut args: Option<Node<'_>> = None;
|
||||
for child in call.named_children(&mut cur) {
|
||||
match child.kind() {
|
||||
"identifier" => {
|
||||
if verb.is_none()
|
||||
&& let Ok(t) = child.utf8_text(bytes)
|
||||
{
|
||||
verb = Some(t);
|
||||
}
|
||||
}
|
||||
"argument_list" => args = Some(child),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let Some(verb) = verb else { return };
|
||||
if !RUBY_ATTACH_VERBS.contains(&verb) {
|
||||
return;
|
||||
}
|
||||
// Emit the verb itself so self-naming forms classify (e.g.
|
||||
// `protect_from_forgery with: :exception` → marker
|
||||
// `protect_from_forgery`).
|
||||
out.push(verb.to_owned());
|
||||
let Some(args) = args else { return };
|
||||
let mut ac = args.walk();
|
||||
for arg in args.named_children(&mut ac) {
|
||||
push_middleware_arg(arg, bytes, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn push_middleware_arg(node: Node<'_>, bytes: &[u8], out: &mut Vec<String>) {
|
||||
match node.kind() {
|
||||
"simple_symbol" => {
|
||||
if let Ok(t) = node.utf8_text(bytes) {
|
||||
let trimmed = t.trim_start_matches(':').trim().to_owned();
|
||||
if !trimmed.is_empty() {
|
||||
out.push(trimmed);
|
||||
}
|
||||
}
|
||||
}
|
||||
"identifier" | "constant" | "scope_resolution" => {
|
||||
if let Ok(t) = node.utf8_text(bytes) {
|
||||
let name = t.trim().to_owned();
|
||||
if !name.is_empty() {
|
||||
out.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -577,4 +708,84 @@ mod tests {
|
|||
let args = call.child_by_field_name("arguments").unwrap();
|
||||
assert_eq!(first_string_arg(args, src), Some("/run".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_rails_before_action_symbol() {
|
||||
let src: &[u8] = b"class UsersController < ApplicationController\n before_action :authenticate_user!\n def index\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert_eq!(mw.len(), 1, "expected exactly one marker, got {mw:?}");
|
||||
assert_eq!(mw[0].name, "authenticate_user!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_rails_protect_from_forgery_self_naming() {
|
||||
// `protect_from_forgery with: :exception` carries no positional
|
||||
// arg — the verb itself must be recognised as the marker.
|
||||
let src: &[u8] = b"class A < ApplicationController\n protect_from_forgery with: :exception\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert!(
|
||||
mw.iter().any(|m| m.name == "protect_from_forgery"),
|
||||
"got {mw:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sinatra_use_rack_auth_basic() {
|
||||
let src: &[u8] = b"require 'sinatra/base'\nclass App < Sinatra::Base\n use Rack::Auth::Basic\n get '/x' do\n 'ok'\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert!(
|
||||
mw.iter().any(|m| m.name == "Rack::Auth::Basic"),
|
||||
"got {mw:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_sinatra_use_rack_attack_rate_limit() {
|
||||
let src: &[u8] =
|
||||
b"require 'sinatra'\nuse Rack::Attack\nget '/x' do\n 'ok'\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert!(mw.iter().any(|m| m.name == "Rack::Attack"), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dedupes_repeated_markers() {
|
||||
let src: &[u8] = b"class A < ApplicationController\n before_action :authenticate_user!\n before_action :authenticate_user!\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert_eq!(mw.len(), 1);
|
||||
assert_eq!(mw[0].name, "authenticate_user!");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_unknown_marker_names() {
|
||||
let src: &[u8] = b"class A < ApplicationController\n before_action :do_something_custom\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
// `do_something_custom` is not in the Ruby auth-markers table.
|
||||
// The verb itself (`before_action`) is also not registered as a
|
||||
// standalone marker — it only flags the call to walk for args.
|
||||
assert!(mw.is_empty(), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_middleware_call_hidden_inside_method_body() {
|
||||
let src: &[u8] = b"class A < ApplicationController\n def helper\n before_action :authenticate_user!\n end\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert!(mw.is_empty(), "got {mw:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_multiple_distinct_markers() {
|
||||
let src: &[u8] = b"class A < ApplicationController\n before_action :authenticate_user!\n protect_from_forgery with: :exception\nend\n";
|
||||
let tree = parse(src);
|
||||
let mw = collect_ruby_middleware(tree.root_node(), src);
|
||||
assert_eq!(mw.len(), 2);
|
||||
assert_eq!(mw[0].name, "authenticate_user!");
|
||||
assert_eq!(mw[1].name, "protect_from_forgery");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,8 @@ use crate::symbol::Lang;
|
|||
use tree_sitter::Node;
|
||||
|
||||
use super::ruby_routes::{
|
||||
bind_path_params, first_string_arg, source_imports_sinatra, verb_from_ident,
|
||||
bind_path_params, collect_ruby_middleware, first_string_arg, source_imports_sinatra,
|
||||
verb_from_ident,
|
||||
};
|
||||
|
||||
pub struct RubySinatraAdapter;
|
||||
|
|
@ -148,6 +149,7 @@ impl FrameworkAdapter for RubySinatraAdapter {
|
|||
.find(|r| path_stem(&r.path) == target)
|
||||
.or_else(|| routes.first())?;
|
||||
let request_params = bind_path_params(&route.block_params, &route.path);
|
||||
let middleware = collect_ruby_middleware(ast, file_bytes);
|
||||
Some(FrameworkBinding {
|
||||
adapter: ADAPTER_NAME.to_owned(),
|
||||
kind: EntryKind::HttpRoute,
|
||||
|
|
@ -157,7 +159,7 @@ impl FrameworkAdapter for RubySinatraAdapter {
|
|||
}),
|
||||
request_params,
|
||||
response_writer: None,
|
||||
middleware: Vec::new(),
|
||||
middleware,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -294,6 +296,23 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn populates_middleware_from_use_rack_attack() {
|
||||
let src: &[u8] = b"require 'sinatra'\nuse Rack::Attack\nget '/run' do |payload|\n payload\nend\n";
|
||||
let tree = parse(src);
|
||||
let binding = RubySinatraAdapter
|
||||
.detect(&summary("run"), tree.root_node(), src)
|
||||
.expect("binding");
|
||||
assert!(
|
||||
binding
|
||||
.middleware
|
||||
.iter()
|
||||
.any(|m| m.name == "Rack::Attack"),
|
||||
"expected Rack::Attack marker, got {:?}",
|
||||
binding.middleware
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_stem_strips_sigils() {
|
||||
assert_eq!(path_stem("/run"), "run");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue