From b7973657cff513209cb7321f90ca2b8283467bd8 Mon Sep 17 00:00:00 2001 From: pitboss Date: Mon, 18 May 2026 14:37:05 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2015:=20Track=20L.13=20?= =?UTF-8?q?=E2=80=94=20Rails=20/=20Sinatra=20/=20Hanami=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/framework/adapters/mod.rs | 7 + src/dynamic/framework/adapters/ruby_hanami.rs | 214 +++++++ src/dynamic/framework/adapters/ruby_rails.rs | 312 ++++++++++ src/dynamic/framework/adapters/ruby_routes.rs | 558 ++++++++++++++++++ .../framework/adapters/ruby_sinatra.rs | 262 ++++++++ src/dynamic/framework/mod.rs | 18 +- src/dynamic/framework/registry.rs | 3 + .../ruby/hanami_action/Gemfile | 8 + .../ruby/hanami_action/benign.rb | 19 + .../ruby/hanami_action/vuln.rb | 17 + tests/ruby_frameworks_corpus.rs | 183 ++++++ 11 files changed, 1592 insertions(+), 9 deletions(-) create mode 100644 src/dynamic/framework/adapters/ruby_hanami.rs create mode 100644 src/dynamic/framework/adapters/ruby_rails.rs create mode 100644 src/dynamic/framework/adapters/ruby_routes.rs create mode 100644 src/dynamic/framework/adapters/ruby_sinatra.rs create mode 100644 tests/dynamic_fixtures/ruby/hanami_action/Gemfile create mode 100644 tests/dynamic_fixtures/ruby/hanami_action/benign.rb create mode 100644 tests/dynamic_fixtures/ruby/hanami_action/vuln.rb create mode 100644 tests/ruby_frameworks_corpus.rs diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 633dbc71..e9db31c8 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -54,7 +54,11 @@ pub mod redirect_python; pub mod redirect_ruby; pub mod redirect_rust; pub mod ruby_erb; +pub mod ruby_hanami; pub mod ruby_marshal; +pub mod ruby_rails; +pub mod ruby_routes; +pub mod ruby_sinatra; pub mod xpath_java; pub mod xpath_js; pub mod xpath_php; @@ -105,7 +109,10 @@ pub use redirect_python::RedirectPythonAdapter; pub use redirect_ruby::RedirectRubyAdapter; pub use redirect_rust::RedirectRustAdapter; pub use ruby_erb::RubyErbAdapter; +pub use ruby_hanami::RubyHanamiAdapter; pub use ruby_marshal::RubyMarshalAdapter; +pub use ruby_rails::RubyRailsAdapter; +pub use ruby_sinatra::RubySinatraAdapter; pub use xpath_java::XpathJavaAdapter; pub use xpath_js::XpathJsAdapter; pub use xpath_php::XpathPhpAdapter; diff --git a/src/dynamic/framework/adapters/ruby_hanami.rs b/src/dynamic/framework/adapters/ruby_hanami.rs new file mode 100644 index 00000000..3e1de949 --- /dev/null +++ b/src/dynamic/framework/adapters/ruby_hanami.rs @@ -0,0 +1,214 @@ +//! Ruby Hanami [`super::super::FrameworkAdapter`] (Phase 15 — Track L.13). +//! +//! Recognises Hanami `Action.call` entry points: a class that either +//! 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`]. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::ruby_routes::{ + bind_path_params, class_extends, class_includes, class_name, find_class_with_method, + method_formal_names, source_imports_hanami, +}; + +pub struct RubyHanamiAdapter; + +const ADAPTER_NAME: &str = "ruby-hanami"; + +fn class_is_hanami_action(class: Node<'_>, bytes: &[u8]) -> bool { + class_extends(class, bytes, "Hanami::Action") + || class_extends(class, bytes, "Action") + || class_includes(class, bytes, "Hanami::Action") +} + +/// Walk the file for a `# nyx-route: ` comment so +/// fixtures can pin an explicit route without needing the Hanami +/// routes DSL. Defaults to `(GET, "/")` if no marker is found. +fn pinned_route(file_bytes: &[u8], fallback_path: &str) -> (HttpMethod, String) { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for line in text.lines() { + let trim = line.trim_start(); + if let Some(rest) = trim.strip_prefix("# nyx-route:") { + let rest = rest.trim(); + let mut parts = rest.split_ascii_whitespace(); + if let (Some(verb), Some(path)) = (parts.next(), parts.next()) { + let method = HttpMethod::from_ident(verb).unwrap_or(HttpMethod::GET); + return (method, path.to_owned()); + } + } + } + (HttpMethod::GET, fallback_path.to_owned()) +} + +fn hanami_default_path(class_name: &str) -> String { + let mut out = String::with_capacity(class_name.len() + 1); + out.push('/'); + for (i, ch) in class_name.char_indices() { + if ch.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +impl FrameworkAdapter for RubyHanamiAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + 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) = pinned_route(file_bytes, &default); + let formals = method_formal_names(method, file_bytes); + let request_params = bind_path_params(&formals, &path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "ruby".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_hanami_action_subclass() { + let src: &[u8] = + b"require 'hanami/action'\nclass Show < Hanami::Action\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_eq!(binding.adapter, "ruby-hanami"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/show"); + } + + #[test] + fn fires_on_include_hanami_action() { + let src: &[u8] = + b"require 'hanami'\nclass List\n include Hanami::Action\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_eq!(binding.adapter, "ruby-hanami"); + assert_eq!(binding.route.unwrap().path, "/list"); + } + + #[test] + fn picks_up_pinned_route_comment() { + let src: &[u8] = b"# nyx-route: POST /save\nrequire 'hanami/action'\nclass Saver < Hanami::Action\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"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/save"); + } + + #[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"; + let tree = parse(src); + let binding = RubyHanamiAdapter + .detect(&summary("call"), tree.root_node(), src) + .expect("binding"); + let id = binding.request_params.iter().find(|p| p.name == "id").unwrap(); + assert!(matches!(id.source, ParamSource::PathSegment(_))); + } + + #[test] + fn req_formal_classed_as_implicit() { + let src: &[u8] = + b"require 'hanami/action'\nclass Show < Hanami::Action\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"); + let req = binding.request_params.iter().find(|p| p.name == "req").unwrap(); + assert!(matches!(req.source, ParamSource::Implicit)); + } + + #[test] + fn skips_non_hanami_classes() { + let src: &[u8] = + b"require 'hanami/action'\nclass Plain\n def call(req)\n 'ok'\n end\nend\n"; + let tree = parse(src); + // No `Hanami::Action` superclass / include — must skip. + assert!(RubyHanamiAdapter + .detect(&summary("call"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_files_without_hanami_marker() { + let src: &[u8] = b"class Show < Hanami::Action\n def call(req)\n 'ok'\n end\nend\n"; + let tree = parse(src); + // The source-import predicate also matches the + // `Hanami::Action` substring, so this fixture in fact does + // trip the marker — the test exists to document that bare + // `Hanami::Action` superclass alone is sufficient. + assert!(RubyHanamiAdapter + .detect(&summary("call"), tree.root_node(), src) + .is_some()); + } +} diff --git a/src/dynamic/framework/adapters/ruby_rails.rs b/src/dynamic/framework/adapters/ruby_rails.rs new file mode 100644 index 00000000..30adacec --- /dev/null +++ b/src/dynamic/framework/adapters/ruby_rails.rs @@ -0,0 +1,312 @@ +//! Ruby Rails [`super::super::FrameworkAdapter`] (Phase 15 — Track L.13). +//! +//! Recognises controller-style action methods declared inside a +//! class that inherits from `ApplicationController` / +//! `ActionController::Base` / `ActionController::API`. When the +//! same file (or, in the Phase 15 fixture path, the same +//! `routes.draw` block we can see at top level) declares a matching +//! `get '/path', to: 'controller#action'` mapping the adapter pulls +//! the explicit path; otherwise the binding falls back to the +//! conventional `/{action}` route + `GET` method so harness +//! emitters still have a usable [`super::super::RouteShape`]. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::ruby_routes::{ + bind_path_params, class_extends, class_name, find_class_with_method, first_string_arg, + kwarg_string, method_formal_names, source_imports_rails, verb_from_ident, +}; + +pub struct RubyRailsAdapter; + +const ADAPTER_NAME: &str = "ruby-rails"; + +fn class_is_rails_controller(class: Node<'_>, bytes: &[u8]) -> bool { + [ + "ApplicationController", + "ActionController::Base", + "ActionController::API", + "Base", + "API", + ] + .iter() + .any(|t| class_extends(class, bytes, t)) +} + +/// Walk the file's top-level `call` nodes looking for a +/// `Rails.application.routes.draw` block or bare `get / post / ...` +/// dispatch lines, and return the first `(method, path)` whose +/// `to: 'controller#action'` kwarg references the target. Returns +/// `None` when no route mapping is present (the caller then falls +/// back to the conventional `/{action}` shape). +fn find_route_mapping<'a>( + root: Node<'a>, + bytes: &'a [u8], + controller: &str, + action: &str, +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + visit_routes(root, bytes, controller, action, &mut hit); + hit +} + +fn visit_routes<'a>( + node: Node<'a>, + bytes: &'a [u8], + controller: &str, + action: &str, + out: &mut Option<(HttpMethod, String)>, +) { + if out.is_some() { + return; + } + if node.kind() == "call" { + if let Some(found) = try_route_mapping(node, bytes, controller, action) { + *out = Some(found); + return; + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + visit_routes(child, bytes, controller, action, out); + } +} + +fn try_route_mapping<'a>( + call: Node<'a>, + bytes: &'a [u8], + controller: &str, + action: &str, +) -> Option<(HttpMethod, String)> { + let mut cur = call.walk(); + let mut verb: Option = None; + let mut args: Option> = None; + for child in call.named_children(&mut cur) { + match child.kind() { + "identifier" => { + if let Ok(name) = child.utf8_text(bytes) { + verb = verb_from_ident(name); + } + } + "argument_list" => args = Some(child), + _ => {} + } + } + let verb = verb?; + let args = args?; + let path = first_string_arg(args, bytes)?; + let to = kwarg_string(args, bytes, "to")?; + let (ctrl, act) = to.split_once('#')?; + if controller_matches(ctrl, controller) && act == action { + return Some((verb, path)); + } + None +} + +/// Match a routes-DSL `controller` name against the Ruby controller +/// class. Rails convention strips the trailing `Controller` suffix +/// and snake-cases: +/// - `UsersController` → `users` +/// - `Api::UsersController` → `api/users` +fn controller_matches(routes_ctrl: &str, controller_class: &str) -> bool { + let expected = rails_controller_path(controller_class); + routes_ctrl == expected +} + +fn rails_controller_path(class_name: &str) -> String { + let stripped = class_name + .strip_suffix("Controller") + .unwrap_or(class_name); + // Rails routes use the singular-segment lower form joined by `/` + // for module-namespaced controllers (`Api::Users` → `api/users`). + let segments: Vec = stripped + .split("::") + .map(|seg| snake_case(seg)) + .filter(|s| !s.is_empty()) + .collect(); + segments.join("/") +} + +fn snake_case(input: &str) -> String { + let mut out = String::with_capacity(input.len() + 4); + for (i, ch) in input.char_indices() { + if ch.is_ascii_uppercase() { + if i > 0 { + out.push('_'); + } + out.push(ch.to_ascii_lowercase()); + } else { + out.push(ch); + } + } + out +} + +impl FrameworkAdapter for RubyRailsAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_rails(file_bytes) { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + if !class_is_rails_controller(class, file_bytes) { + return None; + } + let controller = class_name(class, file_bytes)?; + + let (http_method, path) = find_route_mapping(ast, file_bytes, controller, &summary.name) + .unwrap_or_else(|| (HttpMethod::GET, format!("/{}", summary.name))); + + let formals = method_formal_names(method, file_bytes); + let request_params = bind_path_params(&formals, &path); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "ruby".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_application_controller_subclass() { + let src: &[u8] = + b"class UsersController < ApplicationController\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.adapter, "ruby-rails"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/index"); + } + + #[test] + fn fires_on_action_controller_base_subclass() { + let src: &[u8] = + b"class UsersController < ActionController::Base\n def show\n 'ok'\n end\nend\n"; + let tree = parse(src); + let binding = RubyRailsAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "ruby-rails"); + } + + #[test] + fn picks_up_routes_draw_mapping() { + let src: &[u8] = b"Rails.application.routes.draw do\n get '/run', to: 'users#index'\nend\n\nclass UsersController < ApplicationController\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"); + let route = binding.route.unwrap(); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); + } + + #[test] + fn routes_draw_post_picks_post_verb() { + let src: &[u8] = b"Rails.application.routes.draw do\n post '/save', to: 'users#save'\nend\n\nclass UsersController < ApplicationController\n def save\n 'ok'\n end\nend\n"; + let tree = parse(src); + let binding = RubyRailsAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().method, HttpMethod::POST); + } + + #[test] + fn routes_draw_with_path_placeholder_binds_segment() { + let src: &[u8] = b"Rails.application.routes.draw do\n get '/u/:id', to: 'users#show'\nend\n\nclass UsersController < ApplicationController\n def show(id)\n id\n end\nend\n"; + let tree = parse(src); + let binding = RubyRailsAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.unwrap(); + assert_eq!(route.path, "/u/:id"); + let id = binding.request_params.iter().find(|p| p.name == "id").unwrap(); + assert!(matches!(id.source, crate::dynamic::framework::ParamSource::PathSegment(_))); + } + + #[test] + fn skips_when_class_is_not_a_controller() { + let src: &[u8] = b"class Foo\n def bar\n 'ok'\n end\nend\n"; + let tree = parse(src); + assert!(RubyRailsAdapter + .detect(&summary("bar"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_target_method_not_present() { + let src: &[u8] = + b"class UsersController < ApplicationController\n def index\n 'ok'\n end\nend\n"; + let tree = parse(src); + assert!(RubyRailsAdapter + .detect(&summary("missing"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_files_without_rails_marker() { + let src: &[u8] = + b"class UsersController < Object\n def index\n 'ok'\n end\nend\n"; + let tree = parse(src); + assert!(RubyRailsAdapter + .detect(&summary("index"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn rails_controller_path_drops_suffix_and_snake_cases() { + assert_eq!(rails_controller_path("UsersController"), "users"); + assert_eq!(rails_controller_path("UserPostsController"), "user_posts"); + assert_eq!( + rails_controller_path("Api::UsersController"), + "api/users" + ); + assert_eq!(rails_controller_path("Foo"), "foo"); + } +} diff --git a/src/dynamic/framework/adapters/ruby_routes.rs b/src/dynamic/framework/adapters/ruby_routes.rs new file mode 100644 index 00000000..ea8daba6 --- /dev/null +++ b/src/dynamic/framework/adapters/ruby_routes.rs @@ -0,0 +1,558 @@ +//! Shared Ruby-route adapter helpers (Phase 15 — Track L.13). +//! +//! The Rails / Sinatra / Hanami adapters all need the same handful +//! of tree-sitter helpers: locate a `class` node by name, locate a +//! `method` inside a class body, enumerate method formal names, +//! extract the path placeholders Rails / Sinatra use (`:id`, +//! `*splat`), and bind formals to request slots. Centralising the +//! helpers here keeps the three adapters terse and lets every +//! framework share the same placeholder-binding semantics. + +use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource}; +use tree_sitter::Node; + +/// True when `bytes` carries any of the well-known Rails import +/// stanzas — full framework markers (`require 'rails'`, +/// `ActionController::Base`) plus the convention-based +/// `ApplicationController` superclass the Phase 15 fixture uses. +pub fn source_imports_rails(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"require 'rails'", + b"require \"rails\"", + b"ActionController::Base", + b"ActionController::API", + b"ApplicationController", + b"Rails.application", + b"# nyx-shape: rails", + ], + ) +} + +/// True when `bytes` carries any of the well-known Sinatra markers +/// — `require 'sinatra'`, `Sinatra::Base` subclass, or a top-level +/// `# nyx-shape: sinatra` annotation. +pub fn source_imports_sinatra(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"require 'sinatra'", + b"require \"sinatra\"", + b"require 'sinatra/base'", + b"require \"sinatra/base\"", + b"Sinatra::Base", + b"Sinatra::Application", + b"# nyx-shape: sinatra", + ], + ) +} + +/// True when `bytes` carries any of the well-known Hanami markers — +/// `require 'hanami'`, `Hanami::Action` superclass / include, or a +/// `# nyx-shape: hanami` annotation. +pub fn source_imports_hanami(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"require 'hanami'", + b"require \"hanami\"", + b"require 'hanami/action'", + b"require \"hanami/action\"", + b"Hanami::Action", + b"Hanami::Controller", + b"# nyx-shape: hanami", + ], + ) +} + +fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool { + needles + .iter() + .any(|n| haystack.windows(n.len()).any(|w| w == *n)) +} + +/// Locate the `(class_node, method_node)` pair whose method's +/// identifier equals `target`. Returns the outermost matching class +/// so the caller can read the class superclass + class-level +/// annotations without re-walking. +pub fn find_class_with_method<'a>( + root: Node<'a>, + bytes: &'a [u8], + target: &str, +) -> Option<(Node<'a>, Node<'a>)> { + let mut hit: Option<(Node<'a>, Node<'a>)> = None; + walk_class(root, bytes, target, &mut hit); + hit +} + +fn walk_class<'a>( + node: Node<'a>, + bytes: &'a [u8], + target: &str, + out: &mut Option<(Node<'a>, Node<'a>)>, +) { + if out.is_some() { + return; + } + if node.kind() == "class" { + if let Some(method) = find_method_in_class(node, bytes, target) { + *out = Some((node, method)); + return; + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_class(child, bytes, target, out); + } +} + +/// Find a `method` node named `target` directly inside a `class` +/// body. Returns `None` when the class has no body or no method of +/// that name. +pub fn find_method_in_class<'a>(class: Node<'a>, bytes: &'a [u8], target: &str) -> Option> { + let body = named_child_of_kind(class, "body_statement")?; + let mut cur = body.walk(); + for member in body.named_children(&mut cur) { + if member.kind() != "method" { + continue; + } + if let Some(name) = method_identifier(member, bytes) { + if name == target { + return Some(member); + } + } + } + None +} + +/// Read the leaf identifier of a `method` node. +pub fn method_identifier<'a>(method: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> { + let mut cur = method.walk(); + for c in method.named_children(&mut cur) { + if c.kind() == "identifier" { + return c.utf8_text(bytes).ok(); + } + } + None +} + +fn named_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option> { + let mut cur = node.walk(); + node.named_children(&mut cur).find(|c| c.kind() == kind) +} + +/// Read the simple name of the class declaration: the first +/// `constant` named child. +pub fn class_name<'a>(class: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> { + let mut cur = class.walk(); + for c in class.named_children(&mut cur) { + if c.kind() == "constant" { + return c.utf8_text(bytes).ok(); + } + } + None +} + +/// Read the superclass text (with `< ` prefix dropped) and reduce +/// scope-resolution chains to their leaf segment. Returns `None` +/// when the class has no superclass. +/// +/// Examples: +/// - `class Foo < Bar` → `Some("Bar")` +/// - `class Foo < Hanami::Action` → `Some("Hanami::Action")` +/// - `class Foo` → `None` +pub fn class_superclass_text<'a>(class: Node<'a>, bytes: &'a [u8]) -> Option { + let sc = named_child_of_kind(class, "superclass")?; + let mut cur = sc.walk(); + for c in sc.named_children(&mut cur) { + let txt = c.utf8_text(bytes).ok()?; + let trimmed = txt.trim(); + if !trimmed.is_empty() && trimmed != "<" { + return Some(trimmed.to_owned()); + } + } + None +} + +/// True when the class's superclass leaf or qualified form equals +/// `target`. Matches both `class A < Hanami::Action` and `class A < +/// Action` when `target == "Hanami::Action"` or `"Action"`. +pub fn class_extends(class: Node<'_>, bytes: &[u8], target: &str) -> bool { + let Some(text) = class_superclass_text(class, bytes) else { + return false; + }; + if text == target { + return true; + } + text.rsplit("::").next().unwrap_or(text.as_str()) == target +} + +/// True when the class body contains an `include` call referencing +/// `target` (Hanami v2 idiom: `include Hanami::Action`). +pub fn class_includes(class: Node<'_>, bytes: &[u8], target: &str) -> bool { + let Some(body) = named_child_of_kind(class, "body_statement") else { + return false; + }; + let mut cur = body.walk(); + for member in body.named_children(&mut cur) { + if member.kind() != "call" && member.kind() != "method_call" { + continue; + } + let mut cc = member.walk(); + let mut saw_include = false; + let mut saw_target = false; + for child in member.named_children(&mut cc) { + if child.kind() == "identifier" { + if child.utf8_text(bytes).ok() == Some("include") { + saw_include = true; + } + continue; + } + if child.kind() == "argument_list" { + let raw = child.utf8_text(bytes).ok().unwrap_or(""); + if raw.contains(target) { + saw_target = true; + } + } + } + if saw_include && saw_target { + return true; + } + } + false +} + +/// Enumerate formal parameter names from a `method` node. Skips the +/// implicit `self` receiver (Ruby methods never declare it). Drops +/// splat / block parameters' sigil so `*args` → `args` and `&blk` → +/// `blk`. +pub fn method_formal_names(method: Node<'_>, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let Some(params) = named_child_of_kind(method, "method_parameters") else { + return out; + }; + let mut cur = params.walk(); + for fp in params.named_children(&mut cur) { + if let Some(name) = parameter_name(fp, bytes) { + out.push(name); + } + } + out +} + +fn parameter_name(node: Node<'_>, bytes: &[u8]) -> Option { + match node.kind() { + "identifier" => node.utf8_text(bytes).ok().map(str::to_owned), + "optional_parameter" + | "keyword_parameter" + | "splat_parameter" + | "hash_splat_parameter" + | "block_parameter" + | "destructured_parameter" => { + let mut cur = node.walk(); + for c in node.named_children(&mut cur) { + if c.kind() == "identifier" { + return c.utf8_text(bytes).ok().map(str::to_owned); + } + if let Some(n) = parameter_name(c, bytes) { + return Some(n); + } + } + None + } + _ => None, + } +} + +/// Extract placeholder names from a Ruby route path template. +/// +/// Supports: +/// - Rails / Sinatra `:id` style: `/u/:id` → `id` +/// - Hanami `{id}` style: `/u/{id}` → `id` +/// - Splat: `/u/*rest` → `rest` +pub fn extract_path_placeholders(path: &str) -> Vec { + let mut out: Vec = Vec::new(); + let mut push = |name: String| { + if !name.is_empty() && !out.iter().any(|n| n == &name) { + out.push(name); + } + }; + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b':' => { + let start = i + 1; + let mut j = start; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + if j > start { + push(path[start..j].to_owned()); + i = j; + continue; + } + } + b'{' => { + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { + let inner = &path[i + 1..i + 1 + end]; + let name = inner.split(':').next().unwrap_or(inner); + push(name.to_owned()); + i += end + 2; + continue; + } + } + b'*' => { + let start = i + 1; + let mut j = start; + while j < bytes.len() && (bytes[j].is_ascii_alphanumeric() || bytes[j] == b'_') { + j += 1; + } + if j > start { + push(path[start..j].to_owned()); + i = j; + continue; + } + } + _ => {} + } + i += 1; + } + out +} + +/// Bind formals to request slots given a Ruby route path template. +/// +/// Names matching the path placeholder list become a +/// [`ParamSource::PathSegment`]; `env`, `request`, `req`, `params` +/// formals become [`ParamSource::Implicit`]; every other formal +/// falls back to a [`ParamSource::QueryParam`] of the same name. +pub fn bind_path_params(formals: &[String], path: &str) -> Vec { + let placeholders = extract_path_placeholders(path); + formals + .iter() + .enumerate() + .map(|(idx, name)| { + let source = if is_implicit_formal(name) { + ParamSource::Implicit + } else if placeholders.iter().any(|p| p == name) { + ParamSource::PathSegment(name.clone()) + } else { + ParamSource::QueryParam(name.clone()) + }; + ParamBinding { + index: idx, + name: name.clone(), + source, + } + }) + .collect() +} + +fn is_implicit_formal(name: &str) -> bool { + matches!(name, "env" | "request" | "req" | "params" | "response" | "res") +} + +/// Read the first positional string-literal argument from an +/// `argument_list` child. Used by every Ruby route adapter to pull +/// a path template out of `get '/run' do ... end` and the Rails +/// router DSL `get '/run', to: 'users#index'`. +pub fn first_string_arg<'a>(args: Node<'a>, bytes: &'a [u8]) -> Option { + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + if c.kind() == "string" { + return Some(string_content(c, bytes)); + } + } + None +} + +/// Read the string content of a Ruby `string` node, stripping the +/// surrounding quote children. +pub fn string_content(node: Node<'_>, bytes: &[u8]) -> String { + let mut cur = node.walk(); + for c in node.named_children(&mut cur) { + if c.kind() == "string_content" { + return c.utf8_text(bytes).unwrap_or("").to_owned(); + } + } + // Fall back to raw text with the outer quotes trimmed. + let raw = node.utf8_text(bytes).unwrap_or("").trim(); + raw.trim_matches(['\'', '"']).to_owned() +} + +/// Look up a keyword argument (`key: value`) inside an +/// `argument_list` and return the string content of its value. +/// Returns `None` when the kwarg is missing or its value is not a +/// string literal. +pub fn kwarg_string<'a>(args: Node<'a>, bytes: &'a [u8], key: &str) -> Option { + let mut cur = args.walk(); + for arg in args.named_children(&mut cur) { + if arg.kind() != "pair" { + continue; + } + let mut pc = arg.walk(); + let mut key_match = false; + for child in arg.named_children(&mut pc) { + if child.kind() == "hash_key_symbol" || child.kind() == "simple_symbol" { + if child.utf8_text(bytes).ok() == Some(key) { + key_match = true; + } + continue; + } + if key_match && child.kind() == "string" { + return Some(string_content(child, bytes)); + } + } + } + None +} + +/// Parse Rails-style verb names (`get`, `post`, `put`, `patch`, +/// `delete`, `head`, `options`). Returns `None` for unrelated +/// identifiers. +pub fn verb_from_ident(ident: &str) -> Option { + match ident { + "get" => Some(HttpMethod::GET), + "post" => Some(HttpMethod::POST), + "put" => Some(HttpMethod::PUT), + "patch" => Some(HttpMethod::PATCH), + "delete" => Some(HttpMethod::DELETE), + "head" => Some(HttpMethod::HEAD), + "options" => Some(HttpMethod::OPTIONS), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn finds_class_and_method() { + let src: &[u8] = b"class V\n def run(x)\n x\n end\nend\n"; + let tree = parse(src); + let (class, method) = find_class_with_method(tree.root_node(), src, "run").unwrap(); + assert_eq!(class.kind(), "class"); + assert_eq!(method.kind(), "method"); + } + + #[test] + fn class_name_reads_constant() { + let src: &[u8] = b"class UsersController < Base\nend\n"; + let tree = parse(src); + let mut cur = tree.root_node().walk(); + let class = tree + .root_node() + .children(&mut cur) + .find(|c| c.kind() == "class") + .unwrap(); + assert_eq!(class_name(class, src), Some("UsersController")); + } + + #[test] + fn class_extends_handles_scope_resolution() { + let src: &[u8] = b"class A < Hanami::Action\nend\n"; + let tree = parse(src); + let mut cur = tree.root_node().walk(); + let class = tree + .root_node() + .children(&mut cur) + .find(|c| c.kind() == "class") + .unwrap(); + assert!(class_extends(class, src, "Hanami::Action")); + assert!(class_extends(class, src, "Action")); + assert!(!class_extends(class, src, "ApplicationController")); + } + + #[test] + fn class_includes_detects_hanami_v2() { + let src: &[u8] = + b"class A\n include Hanami::Action\n def call(req)\n end\nend\n"; + let tree = parse(src); + let mut cur = tree.root_node().walk(); + let class = tree + .root_node() + .children(&mut cur) + .find(|c| c.kind() == "class") + .unwrap(); + assert!(class_includes(class, src, "Hanami::Action")); + } + + #[test] + fn extracts_rails_placeholders() { + assert_eq!(extract_path_placeholders("/u/:id"), vec!["id"]); + assert_eq!( + extract_path_placeholders("/u/:id/posts/:slug"), + vec!["id", "slug"] + ); + assert_eq!(extract_path_placeholders("/files/*rest"), vec!["rest"]); + } + + #[test] + fn extracts_hanami_placeholders() { + assert_eq!(extract_path_placeholders("/u/{id}"), vec!["id"]); + } + + #[test] + fn binds_known_placeholder_as_path_segment() { + let formals = vec!["id".to_string(), "extra".to_string()]; + let bindings = bind_path_params(&formals, "/u/:id"); + assert!(matches!(bindings[0].source, ParamSource::PathSegment(_))); + assert!(matches!(bindings[1].source, ParamSource::QueryParam(_))); + } + + #[test] + fn binds_env_request_as_implicit() { + let formals = vec!["env".to_string(), "request".to_string(), "req".to_string()]; + let bindings = bind_path_params(&formals, "/run"); + for b in &bindings { + assert!(matches!(b.source, ParamSource::Implicit)); + } + } + + #[test] + fn method_formal_names_skip_splat_sigils() { + let src: &[u8] = b"class V\n def run(req, *rest, &blk)\n req\n end\nend\n"; + let tree = parse(src); + let (_, method) = find_class_with_method(tree.root_node(), src, "run").unwrap(); + let names = method_formal_names(method, src); + assert_eq!(names, vec!["req", "rest", "blk"]); + } + + #[test] + fn kwarg_string_pulls_value() { + let src: &[u8] = b"get '/run', to: 'users#index'\n"; + let tree = parse(src); + let mut cur = tree.root_node().walk(); + let call = tree + .root_node() + .children(&mut cur) + .find(|c| c.kind() == "call") + .unwrap(); + let args = call.child_by_field_name("arguments").unwrap(); + assert_eq!(kwarg_string(args, src, "to"), Some("users#index".into())); + } + + #[test] + fn first_string_arg_pulls_literal() { + let src: &[u8] = b"get '/run' do |p|\n p\nend\n"; + let tree = parse(src); + let mut cur = tree.root_node().walk(); + let call = tree + .root_node() + .children(&mut cur) + .find(|c| c.kind() == "call") + .unwrap(); + let args = call.child_by_field_name("arguments").unwrap(); + assert_eq!(first_string_arg(args, src), Some("/run".into())); + } +} diff --git a/src/dynamic/framework/adapters/ruby_sinatra.rs b/src/dynamic/framework/adapters/ruby_sinatra.rs new file mode 100644 index 00000000..b3de1b6d --- /dev/null +++ b/src/dynamic/framework/adapters/ruby_sinatra.rs @@ -0,0 +1,262 @@ +//! Ruby Sinatra [`super::super::FrameworkAdapter`] (Phase 15 — Track L.13). +//! +//! Recognises two Sinatra route shapes: +//! +//! - Top-level block form: `get '/run' do |payload| ... end` +//! - Class-form modular: `class App < Sinatra::Base\n get '/x' do ... end\nend` +//! +//! Sinatra blocks are anonymous, so the adapter maps `summary.name` +//! to the route by treating the last path segment (with any leading +//! `:` placeholder sigil stripped) as the function name. When that +//! deterministic match fails the adapter falls back to the first +//! route declared in the file so a single-route Sinatra script still +//! lights up the binding. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::ruby_routes::{ + bind_path_params, first_string_arg, source_imports_sinatra, verb_from_ident, +}; + +pub struct RubySinatraAdapter; + +const ADAPTER_NAME: &str = "ruby-sinatra"; + +/// One route declaration extracted from the file. +struct SinatraRoute { + method: HttpMethod, + path: String, + block_params: Vec, +} + +fn collect_routes(root: Node<'_>, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + visit(root, bytes, &mut out); + out +} + +fn visit(node: Node<'_>, bytes: &[u8], out: &mut Vec) { + if node.kind() == "call" { + if let Some(route) = try_route(node, bytes) { + out.push(route); + return; + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + visit(child, bytes, out); + } +} + +fn try_route(call: Node<'_>, bytes: &[u8]) -> Option { + let mut cur = call.walk(); + let mut verb: Option = None; + let mut args: Option> = None; + let mut block: Option> = None; + for child in call.named_children(&mut cur) { + match child.kind() { + "identifier" => { + if let Ok(name) = child.utf8_text(bytes) { + verb = verb_from_ident(name); + } + } + "argument_list" => args = Some(child), + "do_block" | "block" => block = Some(child), + _ => {} + } + } + let verb = verb?; + let args = args?; + // The block argument is mandatory — a route without an attached + // block is a `routes.draw` mapping (handled by ruby_rails) and + // must not be claimed by the Sinatra adapter. + let block = block?; + let path = first_string_arg(args, bytes)?; + let block_params = block_parameter_names(block, bytes); + Some(SinatraRoute { + method: verb, + path, + block_params, + }) +} + +fn block_parameter_names(block: Node<'_>, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut cur = block.walk(); + for child in block.named_children(&mut cur) { + if child.kind() != "block_parameters" { + continue; + } + let mut bc = child.walk(); + for p in child.named_children(&mut bc) { + if p.kind() == "identifier" { + if let Ok(t) = p.utf8_text(bytes) { + out.push(t.to_owned()); + } + } + } + } + out +} + +/// Strip leading `/` and any `:` placeholder sigil, then return the +/// last path segment. `/users/:id` → `id`, `/run` → `run`. +fn path_stem(path: &str) -> String { + let last = path.rsplit('/').find(|s| !s.is_empty()).unwrap_or(""); + last.trim_start_matches(':') + .trim_start_matches('*') + .to_owned() +} + +impl FrameworkAdapter for RubySinatraAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Ruby + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_sinatra(file_bytes) { + return None; + } + let routes = collect_routes(ast, file_bytes); + if routes.is_empty() { + return None; + } + let target = summary.name.as_str(); + let route = routes + .iter() + .find(|r| path_stem(&r.path) == target) + .or_else(|| routes.first())?; + let request_params = bind_path_params(&route.block_params, &route.path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: route.method, + path: route.path.clone(), + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "ruby".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_top_level_get_block() { + let src: &[u8] = b"require 'sinatra'\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_eq!(binding.adapter, "ruby-sinatra"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/run"); + } + + #[test] + fn fires_on_marker_comment() { + let src: &[u8] = + b"# nyx-shape: sinatra\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_eq!(binding.adapter, "ruby-sinatra"); + } + + #[test] + fn binds_path_placeholder() { + let src: &[u8] = + b"require 'sinatra'\nget '/u/:id' do |id|\n id\nend\n"; + let tree = parse(src); + let binding = RubySinatraAdapter + .detect(&summary("id"), tree.root_node(), src) + .expect("binding"); + let id = binding.request_params.iter().find(|p| p.name == "id").unwrap(); + assert!(matches!(id.source, ParamSource::PathSegment(_))); + } + + #[test] + fn skips_routes_draw_without_block() { + let src: &[u8] = b"require 'sinatra'\nget '/run', to: 'users#index'\n"; + let tree = parse(src); + // No do/end block — the Sinatra adapter must not claim a + // Rails-style `routes.draw` mapping. + assert!(RubySinatraAdapter + .detect(&summary("run"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn falls_back_to_first_route_when_name_does_not_match_stem() { + let src: &[u8] = + b"require 'sinatra'\nget '/alpha' do |p|\n p\nend\nget '/beta' do |p|\n p\nend\n"; + let tree = parse(src); + let binding = RubySinatraAdapter + .detect(&summary("gamma"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/alpha"); + } + + #[test] + fn skips_when_sinatra_not_imported() { + let src: &[u8] = b"get '/run' do |p|\n p\nend\n"; + let tree = parse(src); + assert!(RubySinatraAdapter + .detect(&summary("run"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn post_verb_recognised() { + let src: &[u8] = b"require 'sinatra'\npost '/save' do |body|\n body\nend\n"; + let tree = parse(src); + let binding = RubySinatraAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().method, HttpMethod::POST); + } + + #[test] + fn path_stem_strips_sigils() { + assert_eq!(path_stem("/run"), "run"); + assert_eq!(path_stem("/u/:id"), "id"); + assert_eq!(path_stem("/files/*rest"), "rest"); + assert_eq!(path_stem("/"), ""); + } +} diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index e5a0aa61..03c21251 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -214,13 +214,13 @@ mod tests { } #[test] - fn registry_baseline_after_phase_14() { - // Phase 14 (Track L.12) adds four Java framework adapters - // (`java-micronaut`, `java-quarkus`, `java-servlet`, - // `java-spring`) to the Java slice, growing it from 7 → 11. - // The Phase 13 baseline for the other languages stays put: - // Python 11, Php 7, Ruby 5, JavaScript 11, TypeScript 4, - // Go 3, Rust 2. C / Cpp stay empty. + fn registry_baseline_after_phase_15() { + // Phase 15 (Track L.13) adds three Ruby framework adapters + // (`ruby-hanami`, `ruby-rails`, `ruby-sinatra`) to the Ruby + // slice, growing it from 5 → 8. The Phase 14 baseline for + // the other languages stays put: Java 11, Python 11, Php 7, + // JavaScript 11, TypeScript 4, Go 3, Rust 2. C / Cpp stay + // empty. let java_registered = registry::adapters_for(Lang::Java); assert_eq!( java_registered.len(), @@ -251,8 +251,8 @@ mod tests { let ruby_registered = registry::adapters_for(Lang::Ruby); assert_eq!( ruby_registered.len(), - 5, - "Ruby must have the J.1 + J.2 + J.3 + J.6 + J.7 adapters", + 8, + "Ruby must have the J.1 + J.2 + J.3 + J.6 + J.7 (5) + L.13 Rails/Sinatra/Hanami (3) adapters", ); for adapter in ruby_registered { assert_eq!(adapter.lang(), Lang::Ruby); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 5df87741..cb6892f9 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -94,7 +94,10 @@ static RUBY: &[&dyn FrameworkAdapter] = &[ &super::adapters::HeaderRubyAdapter, &super::adapters::RedirectRubyAdapter, &super::adapters::RubyErbAdapter, + &super::adapters::RubyHanamiAdapter, &super::adapters::RubyMarshalAdapter, + &super::adapters::RubyRailsAdapter, + &super::adapters::RubySinatraAdapter, &super::adapters::XxeRubyAdapter, ]; static TYPESCRIPT: &[&dyn FrameworkAdapter] = &[ diff --git a/tests/dynamic_fixtures/ruby/hanami_action/Gemfile b/tests/dynamic_fixtures/ruby/hanami_action/Gemfile new file mode 100644 index 00000000..d4195fab --- /dev/null +++ b/tests/dynamic_fixtures/ruby/hanami_action/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +# Phase 15 fixture — Hanami Action shape. The adapter only inspects +# the class superclass / include list; the harness never actually +# boots `Hanami::Application`, so the gem is informational for +# cargo-side fixture pickup. +gem 'hanami' +gem 'hanami-controller' diff --git a/tests/dynamic_fixtures/ruby/hanami_action/benign.rb b/tests/dynamic_fixtures/ruby/hanami_action/benign.rb new file mode 100644 index 00000000..d5e25696 --- /dev/null +++ b/tests/dynamic_fixtures/ruby/hanami_action/benign.rb @@ -0,0 +1,19 @@ +# Phase 15 — Hanami Action.call, benign. +# Validates payload before running the fixed echo. + +# nyx-shape: hanami +# nyx-route: GET /run +require 'hanami/action' + +class RunAction < Hanami::Action + def call(req) + payload = req && req.is_a?(Hash) ? (req['nyx.payload'] || '') : (ENV['NYX_PAYLOAD'] || '') + unless payload =~ /\A[A-Za-z0-9]{1,32}\z/ + STDOUT.print("invalid\n") + return "invalid" + end + out = `echo hello` + STDOUT.print(out) + out + end +end diff --git a/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb b/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb new file mode 100644 index 00000000..98d89c05 --- /dev/null +++ b/tests/dynamic_fixtures/ruby/hanami_action/vuln.rb @@ -0,0 +1,17 @@ +# Phase 15 — Hanami Action.call, vulnerable. +# Class includes Hanami::Action and exposes a `call` method that pipes +# the request body into /bin/sh. + +# nyx-shape: hanami +# nyx-route: GET /run +require 'hanami/action' + +class RunAction < Hanami::Action + def call(req) + STDOUT.print("__NYX_SINK_HIT__\n") + payload = req && req.is_a?(Hash) ? (req['nyx.payload'] || '') : (ENV['NYX_PAYLOAD'] || '') + out = `echo hello #{payload}` + STDOUT.print(out) + out + end +end diff --git a/tests/ruby_frameworks_corpus.rs b/tests/ruby_frameworks_corpus.rs new file mode 100644 index 00000000..01b51c31 --- /dev/null +++ b/tests/ruby_frameworks_corpus.rs @@ -0,0 +1,183 @@ +//! Phase 15 (Track L.13) — Ruby framework adapter integration tests. +//! +//! Each test exercises `detect_binding` end-to-end against a fixture +//! file under `tests/dynamic_fixtures/ruby/`, asserting that the +//! right adapter fires, the binding carries +//! `EntryKind::HttpRoute`, and the `RouteShape` matches the brief's +//! contract. Benign fixtures must produce the same adapter binding +//! shape as the vuln fixtures — the adapter only models the route, +//! the differential outcome of a verifier run is what distinguishes +//! the two. + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource}; +use nyx_scanner::evidence::EntryKind; +use nyx_scanner::summary::FuncSummary; +use nyx_scanner::symbol::Lang; + +fn parse_ruby(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_ruby::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() +} + +fn summary_for(name: &str, file: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file.into(), + lang: "ruby".into(), + ..Default::default() + } +} + +// ── Rails ──────────────────────────────────────────────────────────────────── + +#[test] +fn rails_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/ruby/rails_action/vuln.rb"; + let bytes = std::fs::read(path).expect("rails vuln fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("index", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("rails adapter must bind"); + assert_eq!(binding.adapter, "ruby-rails"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/index"); +} + +#[test] +fn rails_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/ruby/rails_action/benign.rb"; + let bytes = std::fs::read(path).expect("rails benign fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("index", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("rails adapter must bind benign fixture"); + assert_eq!(binding.adapter, "ruby-rails"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/index"); +} + +#[test] +fn rails_routes_draw_overrides_default_path() { + let src: &[u8] = b"Rails.application.routes.draw do\n get '/run', to: 'users#index'\nend\n\nclass UsersController < ApplicationController\n def index\n 'ok'\n end\nend\n"; + let tree = parse_ruby(src); + let summary = summary_for("index", "synth.rb"); + let binding = detect_binding(&summary, tree.root_node(), src, Lang::Ruby) + .expect("rails adapter must bind via routes.draw"); + assert_eq!(binding.adapter, "ruby-rails"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +// ── Sinatra ────────────────────────────────────────────────────────────────── + +#[test] +fn sinatra_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb"; + let bytes = std::fs::read(path).expect("sinatra vuln fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("run", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("sinatra adapter must bind"); + assert_eq!(binding.adapter, "ruby-sinatra"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/run"); + let payload_binding = binding + .request_params + .iter() + .find(|p| p.name == "payload") + .expect("payload block param"); + assert!(matches!(payload_binding.source, ParamSource::QueryParam(_))); +} + +#[test] +fn sinatra_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/ruby/sinatra_route/benign.rb"; + let bytes = std::fs::read(path).expect("sinatra benign fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("run", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("sinatra adapter must bind benign fixture"); + assert_eq!(binding.adapter, "ruby-sinatra"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); +} + +// ── Hanami ─────────────────────────────────────────────────────────────────── + +#[test] +fn hanami_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/ruby/hanami_action/vuln.rb"; + let bytes = std::fs::read(path).expect("hanami vuln fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("call", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("hanami adapter must bind"); + assert_eq!(binding.adapter, "ruby-hanami"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/run"); + let req_binding = binding + .request_params + .iter() + .find(|p| p.name == "req") + .expect("req formal"); + assert!(matches!(req_binding.source, ParamSource::Implicit)); +} + +#[test] +fn hanami_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/ruby/hanami_action/benign.rb"; + let bytes = std::fs::read(path).expect("hanami benign fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("call", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("hanami adapter must bind benign fixture"); + assert_eq!(binding.adapter, "ruby-hanami"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); +} + +// ── Cross-adapter disambiguation ───────────────────────────────────────────── + +#[test] +fn sinatra_does_not_fire_on_rails_controller() { + let path = "tests/dynamic_fixtures/ruby/rails_action/vuln.rb"; + let bytes = std::fs::read(path).expect("rails vuln fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("index", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby) + .expect("adapter binds"); + // First-match-wins ordering must produce `ruby-rails`, not + // `ruby-sinatra`, even if both adapters could in theory match. + assert_eq!(binding.adapter, "ruby-rails"); +} + +#[test] +fn hanami_does_not_fire_on_plain_class_with_call_method() { + let path = "tests/dynamic_fixtures/ruby/rack_middleware/vuln.rb"; + let bytes = std::fs::read(path).expect("rack vuln fixture exists"); + let tree = parse_ruby(&bytes); + let summary = summary_for("call", path); + let binding_opt = detect_binding(&summary, tree.root_node(), &bytes, Lang::Ruby); + // The rack_middleware fixture has no Hanami::Action import or + // superclass; Hanami must not claim it. No other Phase 15 route + // adapter matches either (no Rails / Sinatra markers), so binding + // is `None` overall for the Phase 15 route slice. Sink adapters + // (header-ruby / redirect-ruby / etc.) also do not fire because + // the rack fixture's callees are not redirect / header sinks. + if let Some(b) = binding_opt { + assert_ne!(b.adapter, "ruby-hanami"); + assert_ne!(b.adapter, "ruby-rails"); + assert_ne!(b.adapter, "ruby-sinatra"); + } +}