From 987fc1d89f3241fa35fea39a2e15a777fa6898fd Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 00:55:00 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0003 (20260522T043516Z-29b8) --- .../framework/adapters/php_codeigniter.rs | 7 +- src/dynamic/framework/adapters/php_laravel.rs | 38 +++- src/dynamic/framework/adapters/php_routes.rs | 193 +++++++++++++++- src/dynamic/framework/adapters/php_symfony.rs | 24 +- src/dynamic/framework/adapters/ruby_hanami.rs | 24 +- src/dynamic/framework/adapters/ruby_rails.rs | 36 ++- src/dynamic/framework/adapters/ruby_routes.rs | 213 +++++++++++++++++- .../framework/adapters/ruby_sinatra.rs | 23 +- 8 files changed, 539 insertions(+), 19 deletions(-) diff --git a/src/dynamic/framework/adapters/php_codeigniter.rs b/src/dynamic/framework/adapters/php_codeigniter.rs index fe7111ad..a786f649 100644 --- a/src/dynamic/framework/adapters/php_codeigniter.rs +++ b/src/dynamic/framework/adapters/php_codeigniter.rs @@ -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, }) } } diff --git a/src/dynamic/framework/adapters/php_laravel.rs b/src/dynamic/framework/adapters/php_laravel.rs index 857e1f2d..48f2e447 100644 --- a/src/dynamic/framework/adapters/php_laravel.rs +++ b/src/dynamic/framework/adapters/php_laravel.rs @@ -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"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"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"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 { + let mut raw: Vec = Vec::new(); + walk_php_middleware(root, bytes, &mut raw); + let mut out: Vec = 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) { + 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) { + 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) { + 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"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"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"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"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"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"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"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:?}"); + } } diff --git a/src/dynamic/framework/adapters/php_symfony.rs b/src/dynamic/framework/adapters/php_symfony.rs index a76320e8..a5c40bcf 100644 --- a/src/dynamic/framework/adapters/php_symfony.rs +++ b/src/dynamic/framework/adapters/php_symfony.rs @@ -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" Option { } } +/// 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 { + let mut raw: Vec = Vec::new(); + walk_attach_calls(root, bytes, &mut raw); + let mut out: Vec = 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) { + 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) { + let mut cur = call.walk(); + let mut verb: Option<&str> = None; + let mut args: Option> = 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) { + 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"); + } } diff --git a/src/dynamic/framework/adapters/ruby_sinatra.rs b/src/dynamic/framework/adapters/ruby_sinatra.rs index a44f1172..88ed1ad1 100644 --- a/src/dynamic/framework/adapters/ruby_sinatra.rs +++ b/src/dynamic/framework/adapters/ruby_sinatra.rs @@ -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");