[pitboss] phase 15: Track L.13 — Rails / Sinatra / Hanami adapters

This commit is contained in:
pitboss 2026-05-18 14:37:05 -05:00
parent 3d3fdc21b7
commit b7973657cf
11 changed files with 1592 additions and 9 deletions

View file

@ -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;

View file

@ -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: <METHOD> <path>` 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<FrameworkBinding> {
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());
}
}

View file

@ -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<HttpMethod> = None;
let mut args: Option<Node<'a>> = 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<String> = 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<FrameworkBinding> {
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");
}
}

View file

@ -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<Node<'a>> {
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<Node<'a>> {
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<String> {
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<String> {
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<String> {
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<String> {
let mut out: Vec<String> = Vec::new();
let mut push = |name: String| {
if !name.is_empty() && !out.iter().any(|n| n == &name) {
out.push(name);
}
};
let bytes = path.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b':' => {
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<ParamBinding> {
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<String> {
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<String> {
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<HttpMethod> {
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()));
}
}

View file

@ -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<String>,
}
fn collect_routes(root: Node<'_>, bytes: &[u8]) -> Vec<SinatraRoute> {
let mut out = Vec::new();
visit(root, bytes, &mut out);
out
}
fn visit(node: Node<'_>, bytes: &[u8], out: &mut Vec<SinatraRoute>) {
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<SinatraRoute> {
let mut cur = call.walk();
let mut verb: Option<HttpMethod> = None;
let mut args: Option<Node<'_>> = None;
let mut block: Option<Node<'_>> = 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<String> {
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<FrameworkBinding> {
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("/"), "");
}
}

View file

@ -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);

View file

@ -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] = &[

View file

@ -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'

View file

@ -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

View file

@ -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

View file

@ -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");
}
}