mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
[pitboss] phase 15: Track L.13 — Rails / Sinatra / Hanami adapters
This commit is contained in:
parent
3d3fdc21b7
commit
b7973657cf
11 changed files with 1592 additions and 9 deletions
|
|
@ -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;
|
||||
|
|
|
|||
214
src/dynamic/framework/adapters/ruby_hanami.rs
Normal file
214
src/dynamic/framework/adapters/ruby_hanami.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
312
src/dynamic/framework/adapters/ruby_rails.rs
Normal file
312
src/dynamic/framework/adapters/ruby_rails.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
558
src/dynamic/framework/adapters/ruby_routes.rs
Normal file
558
src/dynamic/framework/adapters/ruby_routes.rs
Normal 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()));
|
||||
}
|
||||
}
|
||||
262
src/dynamic/framework/adapters/ruby_sinatra.rs
Normal file
262
src/dynamic/framework/adapters/ruby_sinatra.rs
Normal 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("/"), "");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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] = &[
|
||||
|
|
|
|||
8
tests/dynamic_fixtures/ruby/hanami_action/Gemfile
Normal file
8
tests/dynamic_fixtures/ruby/hanami_action/Gemfile
Normal 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'
|
||||
19
tests/dynamic_fixtures/ruby/hanami_action/benign.rb
Normal file
19
tests/dynamic_fixtures/ruby/hanami_action/benign.rb
Normal 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
|
||||
17
tests/dynamic_fixtures/ruby/hanami_action/vuln.rb
Normal file
17
tests/dynamic_fixtures/ruby/hanami_action/vuln.rb
Normal 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
|
||||
183
tests/ruby_frameworks_corpus.rs
Normal file
183
tests/ruby_frameworks_corpus.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue