refactor(dynamic): enhance Ruby harness with framework-specific route replay logic (Sinatra, Rails, Hanami), extend Gemfile staging, and update tests/fixtures

This commit is contained in:
elipeter 2026-05-26 12:59:02 -05:00
parent 41c7b73575
commit aaf49acefb
20 changed files with 773 additions and 218 deletions

View file

@ -361,6 +361,150 @@ fn build_cache_path(
Ok(path)
}
// ── Ruby build sandbox ───────────────────────────────────────────────────────
/// Prepare Ruby dependencies for `spec` in `workdir`.
///
/// Runs `bundle check` first so hosts that already have the declared gems do
/// not need network access. When the check misses, runs `bundle install` into
/// `vendor/bundle` and caches both that tree and Bundler's local config.
pub fn prepare_ruby(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
if !workdir.join("Gemfile").exists() {
return Ok(BuildResult {
venv_path: workdir.to_path_buf(),
cache_hit: false,
duration: Duration::ZERO,
});
}
let lockfile_hash = compute_ruby_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "ruby", &spec.toolchain_id).ok();
if let Some(cache_path) = &cache_path
&& cache_path.join(".ruby_cache_done").exists()
{
restore_cached_ruby_bundle(cache_path, workdir);
return Ok(BuildResult {
venv_path: cache_path.clone(),
cache_hit: true,
duration: Duration::ZERO,
});
}
let start = Instant::now();
const MAX_ATTEMPTS: u32 = 2;
const BACKOFF: [u64; 2] = [1, 4];
let mut last_err = String::new();
for attempt in 0..MAX_ATTEMPTS {
if attempt > 0 {
std::thread::sleep(Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_bundle_install(workdir) {
Ok(()) => {
if let Some(cache_path) = &cache_path {
persist_ruby_bundle(workdir, cache_path);
let _ = std::fs::write(cache_path.join(".ruby_cache_done"), b"done");
}
return Ok(BuildResult {
venv_path: cache_path.unwrap_or_else(|| workdir.to_path_buf()),
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
}
}
}
Err(BuildError::BuildFailed {
stderr: last_err,
attempts: MAX_ATTEMPTS,
})
}
fn try_bundle_install(workdir: &Path) -> Result<(), String> {
let bundle = std::env::var("NYX_BUNDLE_BIN").unwrap_or_else(|_| "bundle".to_owned());
if bundle_check(&bundle, workdir)? {
return Ok(());
}
let config = Command::new(&bundle)
.args(["config", "set", "--local", "path", "vendor/bundle"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("bundle config: {e}"))?;
if !config.status.success() {
return Err(String::from_utf8_lossy(&config.stderr).into_owned());
}
let output = Command::new(&bundle)
.args(["install", "--jobs", "4", "--retry", "2"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("bundle install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn bundle_check(bundle: &str, workdir: &Path) -> Result<bool, String> {
let output = Command::new(bundle)
.arg("check")
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.output()
.map_err(|e| format!("bundle check: {e}"))?;
Ok(output.status.success())
}
fn restore_cached_ruby_bundle(cache_path: &Path, workdir: &Path) {
let cached_vendor = cache_path.join("vendor").join("bundle");
if cached_vendor.exists() && !workdir.join("vendor").join("bundle").exists() {
let _ = copy_dir_all(&cached_vendor, &workdir.join("vendor").join("bundle"));
}
let cached_bundle_config = cache_path.join(".bundle");
if cached_bundle_config.exists() && !workdir.join(".bundle").exists() {
let _ = copy_dir_all(&cached_bundle_config, &workdir.join(".bundle"));
}
}
fn persist_ruby_bundle(workdir: &Path, cache_path: &Path) {
let vendor = workdir.join("vendor").join("bundle");
if vendor.exists() {
let _ = copy_dir_all(&vendor, &cache_path.join("vendor").join("bundle"));
}
let bundle_config = workdir.join(".bundle");
if bundle_config.exists() {
let _ = copy_dir_all(&bundle_config, &cache_path.join(".bundle"));
}
}
fn compute_ruby_lockfile_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["Gemfile", "Gemfile.lock"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
let out = h.finalize();
format!(
"{:016x}",
u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap())
)
}
// ── Node.js build sandbox ─────────────────────────────────────────────────────
/// Prepare a Node.js project for `spec` in `workdir`.
@ -646,35 +790,37 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, B
// from the same sources targeted at `--release 21`.
let target_release = java_target_release(&spec.toolchain_id);
let source_hash = compute_java_source_hash(workdir, target_release);
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id)?;
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id).ok();
let cached_classes = collect_class_files(&cache_path);
if let Some(cache_path) = &cache_path {
let cached_classes = collect_class_files(cache_path);
// Cache hit: at least the harness class is compiled. Restore every
// cached `.class` to workdir so the classpath (which points to
// workdir, not cache_path) can find them when a different finding
// hits the same compiled artefact via a fresh spec_hash.
if cache_path.join("NyxHarness.class").exists() {
for cls in &cached_classes {
let src = cache_path.join(cls);
let dst = workdir.join(cls);
if src.exists() && !dst.exists() {
let _ = std::fs::copy(&src, &dst);
// Cache hit: at least the harness class is compiled. Restore every
// cached `.class` to workdir so the classpath (which points to
// workdir, not cache_path) can find them when a different finding
// hits the same compiled artefact via a fresh spec_hash.
if cache_path.join("NyxHarness.class").exists() {
for cls in &cached_classes {
let src = cache_path.join(cls);
let dst = workdir.join(cls);
if src.exists() && !dst.exists() {
let _ = std::fs::copy(&src, &dst);
}
}
// Restore cached Maven-resolved jars when the harness shipped a
// `pom.xml`; the harness command embeds `-cp .:lib/*` so the
// runtime classpath needs these jars staged in the workdir.
let cached_lib = cache_path.join("lib");
let workdir_lib = workdir.join("lib");
if cached_lib.exists() && !workdir_lib.exists() {
let _ = copy_dir_all(&cached_lib, &workdir_lib);
}
return Ok(BuildResult {
venv_path: cache_path.clone(),
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
// Restore cached Maven-resolved jars when the harness shipped a
// `pom.xml`; the harness command embeds `-cp .:lib/*` so the
// runtime classpath needs these jars staged in the workdir.
let cached_lib = cache_path.join("lib");
let workdir_lib = workdir.join("lib");
if cached_lib.exists() && !workdir_lib.exists() {
let _ = copy_dir_all(&cached_lib, &workdir_lib);
}
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::Instant::now();
@ -688,10 +834,12 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, B
BACKOFF[attempt as usize - 1],
));
}
match try_compile_java(workdir, &cache_path, target_release) {
let compile_cache = cache_path.as_deref().unwrap_or(workdir);
match try_compile_java(workdir, compile_cache, target_release) {
Ok(()) => {
let build_root = cache_path.clone().unwrap_or_else(|| workdir.to_path_buf());
return Ok(BuildResult {
venv_path: cache_path,
venv_path: build_root,
cache_hit: false,
duration: start.elapsed(),
});
@ -700,7 +848,9 @@ pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, B
last_err = e;
// Best-effort clean-up: drop every cached `.class` so the
// next attempt re-compiles from source.
if let Ok(entries) = std::fs::read_dir(&cache_path) {
if let Some(cache_path) = &cache_path
&& let Ok(entries) = std::fs::read_dir(cache_path)
{
for entry in entries.flatten() {
if entry
.path()
@ -797,25 +947,27 @@ fn try_compile_java(
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
// Copy class files to cache. `javac -d workdir` writes nested
// package directories under workdir; preserve the relative layout
// when caching so the restore path can recreate them.
for cls in collect_class_files(workdir) {
let src = workdir.join(&cls);
let dst = cache_path.join(&cls);
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
if cache_path != workdir {
// Copy class files to cache. `javac -d workdir` writes nested
// package directories under workdir; preserve the relative layout
// when caching so the restore path can recreate them.
for cls in collect_class_files(workdir) {
let src = workdir.join(&cls);
let dst = cache_path.join(&cls);
if let Some(parent) = dst.parent() {
let _ = std::fs::create_dir_all(parent);
}
if src.exists() {
let _ = std::fs::copy(&src, &dst);
}
}
if src.exists() {
let _ = std::fs::copy(&src, &dst);
}
}
// Persist Maven-resolved jars alongside the class cache so cache-hit
// restores can rebuild the `lib/` classpath without re-running mvn.
if lib_on_cp {
let lib_src = workdir.join("lib");
if lib_src.exists() {
let _ = copy_dir_all(&lib_src, &cache_path.join("lib"));
// Persist Maven-resolved jars alongside the class cache so cache-hit
// restores can rebuild the `lib/` classpath without re-running mvn.
if lib_on_cp {
let lib_src = workdir.join("lib");
if lib_src.exists() {
let _ = copy_dir_all(&lib_src, &cache_path.join("lib"));
}
}
}
Ok(())
@ -1333,10 +1485,7 @@ pub struct ChainStepBuildResult {
/// build its own per-step command vector.
///
/// `profile` is consulted only on [`Lang::C`] (drives `-static`); the
/// other per-language preparers ignore it. [`Lang::Ruby`] returns
/// [`BuildError::Unsupported`] because there is no `prepare_ruby` —
/// the runner's match arm falls through to a `_ => {}` no-op for Ruby
/// today, so the reverifier mirrors that contract.
/// other per-language preparers ignore it.
pub fn dispatch_prepare(
spec: &HarnessSpec,
workdir: &Path,
@ -1350,9 +1499,9 @@ pub fn dispatch_prepare(
Lang::Go => prepare_go(spec, workdir)?,
Lang::Java => prepare_java(spec, workdir)?,
Lang::Php => prepare_php(spec, workdir)?,
Lang::Ruby => prepare_ruby(spec, workdir)?,
Lang::C => prepare_c(spec, workdir, profile)?,
Lang::Cpp => prepare_cpp(spec, workdir)?,
Lang::Ruby => return Err(BuildError::Unsupported),
};
Ok(ChainStepBuildResult {
lang,
@ -1949,18 +2098,21 @@ mod tests {
}
#[test]
fn dispatch_prepare_ruby_returns_unsupported() {
// Ruby has no prepare_ruby — the runner falls through to a `_`
// no-op for it. The dispatcher mirrors that contract so the
// composite-chain reverifier can distinguish "build skipped"
// from "build failed" instead of silently producing a result.
fn dispatch_prepare_ruby_routes_to_bundler_no_gemfile_path() {
// Ruby now has the same dependency-prep leg as the other
// interpreted framework harnesses. With no Gemfile present,
// prepare_ruby takes the cheap path and records an empty cache
// entry without invoking Bundler.
let _lock = ENV_LOCK.lock().unwrap();
let _cache = BuildCacheGuard::isolated();
let dir = tempfile::TempDir::new().unwrap();
let spec = mk_spec(Lang::Ruby, "ruby-unsupported");
let result = dispatch_prepare(&spec, dir.path(), ProcessHardeningProfile::Standard);
assert!(
matches!(result, Err(BuildError::Unsupported)),
"Ruby must route to BuildError::Unsupported; got {result:?}",
);
let spec = mk_spec(Lang::Ruby, "ruby-no-gemfile");
let result = dispatch_prepare(&spec, dir.path(), ProcessHardeningProfile::Standard)
.expect("Ruby dispatch must succeed on a workdir with no Gemfile");
assert_eq!(result.lang, Lang::Ruby);
assert!(!result.cache_hit);
assert_eq!(result.duration, Duration::ZERO);
assert!(result.build_root.exists());
}
#[test]

View file

@ -4211,7 +4211,10 @@ mod tests {
assert!(harness.source.contains("nyxPayload()"));
assert!(harness.source.contains("Entry.processInput(payload)"));
assert_eq!(harness.filename, "NyxHarness.java");
assert_eq!(harness.command, vec!["java", "-cp", ".", "NyxHarness"]);
assert_eq!(
harness.command,
vec!["java", "-cp", ".:lib/*", "NyxHarness"]
);
}
#[test]

View file

@ -3,13 +3,15 @@
//! Phase 15 (Track B Ruby vertical) replaces the previous `LangUnsupported`
//! stub with dispatch over [`RubyShape`] — the cross product of
//! [`EntryKind`] and a lightweight per-file shape detector that inspects
//! the entry file for Sinatra routes, Rails controller actions, Rack
//! middleware, and generic controller methods.
//! the entry file for Sinatra routes, Rails controller actions, Hanami
//! actions, Rack middleware, and generic controller methods.
//!
//! Each shape emits a single `harness.rb` that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Requires the entry file from the workdir (`entry.rb`).
//! 3. Invokes the entry point via the per-shape adapter.
//! 3. Invokes the entry point via the per-shape adapter. Framework routes
//! are replayed through Rack / ActionController / Hanami request entry
//! points instead of an in-process route registry.
//!
//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__`
//! before the actual sink call (same pattern as Rust / JS / Go fixtures).
@ -17,14 +19,13 @@
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set `ENV[name]` before calling.
//! - `PayloadSlot::QueryParam(name)` — surfaced via the per-shape
//! request stub for Sinatra / Rails / Rack.
//! - `PayloadSlot::HttpBody` — surfaced via the per-shape request stub
//! for Sinatra / Rails / Rack.
//! - `PayloadSlot::QueryParam(name)` — surfaced via the Rack request.
//! - `PayloadSlot::HttpBody` — surfaced via the Rack request body.
//! - `PayloadSlot::Argv(n)` — appended to `ARGV` for CLI-style entries.
//! - `PayloadSlot::Stdin` — produces `UnsupportedReason::PayloadSlotUnsupported`.
//!
//! Build: no compilation step. Command is `ruby harness.rb`.
//! Build: no compilation step. When the emitter stages a Gemfile,
//! `build_sandbox::prepare_ruby` runs Bundler before `ruby harness.rb`.
use crate::dynamic::environment::{Environment, RuntimeArtifacts};
use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter};
@ -37,8 +38,8 @@ pub struct RubyEmitter;
/// Entry kinds the Ruby emitter understands after Phase 15.
///
/// `HttpRoute` covers Sinatra / Rails / Rack. `CliSubcommand` covers
/// `ARGV`-driven scripts. `Function` covers plain methods and
/// `HttpRoute` covers Sinatra / Rails / Hanami / Rack. `CliSubcommand`
/// covers `ARGV`-driven scripts. `Function` covers plain methods and
/// controller method shapes.
const SUPPORTED: &[EntryKindTag] = &[
EntryKindTag::Function,
@ -131,17 +132,18 @@ fn ruby_string_literal(s: &str) -> String {
/// or no marker fires the detector defaults to [`RubyShape::Generic`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RubyShape {
/// `get '/path' do ... end` Sinatra route. Harness publishes the
/// payload via `ENV` + `$nyx_request` and triggers the route's
/// block via `$nyx_sinatra_routes`.
/// `get '/path' do ... end` Sinatra route. Harness sends a Rack
/// request into the detected Sinatra app.
SinatraRoute,
/// Rails controller action (e.g. `def index ... end` on a class
/// inheriting from `ApplicationController` / `ActionController::Base`).
/// Harness instantiates the controller and calls the action with a
/// stub `request` / `params` pair.
/// Harness calls the controller's Rack endpoint.
RailsAction,
/// Hanami action (`class RunAction < Hanami::Action` with `call`).
/// Harness invokes the real action object with a Rack env.
HanamiAction,
/// Rack middleware: `def call(env) ... end` on a class. Harness
/// builds a minimal Rack `env` hash and dispatches.
/// builds the env through Rack and dispatches the app.
RackMiddleware,
/// Generic instance method on a controller class (no framework
/// marker). Harness instantiates the class with `.new` and calls
@ -168,6 +170,10 @@ impl RubyShape {
|| source.contains("ActionController::Base")
|| source.contains("ActionController::API")
|| source.contains("# nyx-shape: rails");
let has_hanami = source.contains("require 'hanami/action'")
|| source.contains("require \"hanami/action\"")
|| source.contains("Hanami::Action")
|| source.contains("# nyx-shape: hanami");
let has_rack = source.contains("def call(env)")
|| source.contains("Rack::")
|| source.contains("# nyx-shape: rack");
@ -188,6 +194,9 @@ impl RubyShape {
if has_rails {
return Self::RailsAction;
}
if has_hanami {
return Self::HanamiAction;
}
if has_rack {
return Self::RackMiddleware;
}
@ -510,16 +519,35 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
let entry_source = read_entry_source(&spec.entry_file);
let shape = RubyShape::detect(spec, &entry_source);
let source = generate_source(spec, shape);
let extra_files = extra_files_for_shape(shape);
Ok(HarnessSource {
source,
filename: "harness.rb".to_owned(),
command: vec!["ruby".to_owned(), "harness.rb".to_owned()],
extra_files: vec![],
extra_files,
entry_subpath: Some("entry.rb".to_owned()),
})
}
fn extra_files_for_shape(shape: RubyShape) -> Vec<(String, String)> {
let deps: &[&str] = match shape {
RubyShape::SinatraRoute => &["rack", "sinatra"],
RubyShape::RailsAction => &["rack", "actionpack"],
RubyShape::HanamiAction => &["rack", "hanami-controller"],
RubyShape::RackMiddleware => &["rack"],
RubyShape::ControllerMethod | RubyShape::Generic => &[],
};
if deps.is_empty() {
return Vec::new();
}
let mut body = String::from("source 'https://rubygems.org'\n");
for dep in deps {
body.push_str(&format!("gem '{dep}'\n"));
}
vec![("Gemfile".to_owned(), body)]
}
/// Phase 19 (Track M.1) — class-method harness for Ruby.
///
/// Requires the entry file, looks up `class` as a top-level constant,
@ -2043,7 +2071,7 @@ STDOUT.flush
fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
let entry_fn = &spec.entry_name;
let pre_call = build_pre_call(spec);
let pre_call = build_pre_call(spec, shape);
let invocation = invoke_for_shape(spec, shape, entry_fn);
let shim = probe_shim();
let crash_callee = if entry_fn.is_empty() {
@ -2053,7 +2081,7 @@ fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String {
};
format!(
r#"# Nyx dynamic harness — auto-generated, do not edit (Phase 15 — RubyShape::{shape:?}).
r#"# Nyx dynamic harness — auto-generated, do not edit (RubyShape::{shape:?}).
{shim}
# Payload loading
def nyx_payload
@ -2073,26 +2101,60 @@ end
$nyx_payload = nyx_payload
begin
require 'uri'
require 'bundler/setup' if File.exist?(File.join(__dir__, 'Gemfile'))
rescue LoadError, StandardError => e
STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}")
exit 77
end
def _nyx_require_rack_mock
require 'rack/mock'
rescue LoadError => e
STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}")
exit 77
end
def _nyx_materialize_path(template, payload)
encoded = URI.encode_www_form_component(payload.to_s)
path = template.to_s.empty? ? '/' : template.to_s
path = path.gsub(/\{{[^}}]+\}}/, encoded)
path.gsub(/:[A-Za-z_][A-Za-z0-9_]*/, encoded)
end
def _nyx_request_uri
params = $nyx_request[:params] || {{}}
query = URI.encode_www_form(params)
path = $nyx_request[:path] || '/'
query.empty? ? path : path.to_s + '?' + query.to_s
end
def _nyx_rack_env
_nyx_require_rack_mock
env = Rack::MockRequest.env_for(
_nyx_request_uri,
method: ($nyx_request[:method] || 'GET'),
input: ($nyx_request[:body] || '')
)
env['nyx.payload'] = $nyx_payload
env
end
def _nyx_print_rack_body(body)
if body.respond_to?(:each)
body.each {{ |chunk| print(chunk.to_s) }}
elsif body
print(body.to_s)
end
end
# Phase 08 sink-site signal trap: install AFTER payload decode so a crash
# inside `nyx_payload` writes no Crash probe and routes the verifier to
# `Inconclusive(UnrelatedCrash)`. A fatal signal inside the entry call
# below DOES fire the handler and writes a Crash probe to `NYX_PROBE_PATH`.
__nyx_install_crash_guard('{crash_callee}')
{pre_call}
# Sinatra route registry
$nyx_sinatra_routes ||= []
unless Object.method_defined?(:__nyx_register_route)
module Kernel
def get(path, &block)
$nyx_sinatra_routes ||= []
$nyx_sinatra_routes << [path, :get, block]
end
def post(path, &block)
$nyx_sinatra_routes ||= []
$nyx_sinatra_routes << [path, :post, block]
end
end
end
# Entry require
begin
@ -2115,79 +2177,198 @@ end
)
}
fn build_pre_call(spec: &HarnessSpec) -> String {
fn build_pre_call(spec: &HarnessSpec, shape: RubyShape) -> String {
let mut out = String::new();
let (method, path_template) = route_for_spec(spec, shape);
let default_param = default_payload_param(spec);
let default_request = format!(
"$nyx_request = {{ method: {method:?}, path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{ {default_param:?} => $nyx_payload }}, body: '' }}\n"
);
match &spec.payload_slot {
PayloadSlot::EnvVar(name) => {
out.push_str(&format!("ENV[{name:?}] = $nyx_payload\n"));
out.push_str(&default_request);
}
PayloadSlot::Argv(n) => {
for _ in 0..*n {
out.push_str("ARGV << ''\n");
}
out.push_str("ARGV << $nyx_payload\n");
out.push_str(&default_request);
}
PayloadSlot::QueryParam(name) => {
out.push_str(&format!(
"$nyx_request = {{ method: 'GET', path: '/', params: {{ {name:?} => $nyx_payload }}, body: '' }}\n"
"$nyx_request = {{ method: {method:?}, path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{ {name:?} => $nyx_payload }}, body: '' }}\n"
));
}
PayloadSlot::HttpBody => {
out.push_str(
"$nyx_request = { method: 'POST', path: '/', params: {}, body: $nyx_payload }\n",
);
out.push_str(&format!(
"$nyx_request = {{ method: 'POST', path: _nyx_materialize_path({path_template:?}, $nyx_payload), params: {{}}, body: $nyx_payload }}\n"
));
}
_ => {
out.push_str(
"$nyx_request = { method: 'GET', path: '/', params: { 'payload' => $nyx_payload }, body: '' }\n",
);
out.push_str(&default_request);
}
}
out
}
fn default_payload_param(spec: &HarnessSpec) -> String {
spec.framework
.as_ref()
.and_then(|binding| {
binding
.request_params
.iter()
.find_map(|param| match &param.source {
crate::dynamic::framework::ParamSource::QueryParam(name)
| crate::dynamic::framework::ParamSource::FormField(name)
| crate::dynamic::framework::ParamSource::PathSegment(name) => {
Some(name.clone())
}
_ => None,
})
})
.unwrap_or_else(|| "payload".to_owned())
}
fn route_for_spec(spec: &HarnessSpec, shape: RubyShape) -> (String, String) {
if let Some(route) = spec
.framework
.as_ref()
.and_then(|binding| binding.route.as_ref())
{
return (
http_method_name(route.method).to_owned(),
route.path.clone(),
);
}
let source = read_entry_source(&spec.entry_file);
if let Some(found) = route_from_source(&source) {
return found;
}
match shape {
RubyShape::RailsAction => ("GET".to_owned(), format!("/{}", spec.entry_name)),
RubyShape::RackMiddleware => ("GET".to_owned(), "/".to_owned()),
RubyShape::SinatraRoute | RubyShape::HanamiAction => ("GET".to_owned(), "/run".to_owned()),
RubyShape::ControllerMethod | RubyShape::Generic => ("GET".to_owned(), "/".to_owned()),
}
}
fn route_from_source(source: &str) -> Option<(String, String)> {
if let Some(found) = pinned_route_from_source(source) {
return Some(found);
}
for line in source.lines() {
let trimmed = line.trim_start();
for verb in ["get", "post", "put", "patch", "delete", "options"] {
if let Some(rest) = trimmed.strip_prefix(verb)
&& rest.starts_with(char::is_whitespace)
&& let Some(path) = first_quoted_string(rest)
{
return Some((verb.to_ascii_uppercase(), path));
}
}
}
None
}
fn pinned_route_from_source(source: &str) -> Option<(String, String)> {
for line in source.lines() {
let trimmed = line.trim_start();
let Some(rest) = trimmed.strip_prefix("# nyx-route:") else {
continue;
};
let mut parts = rest.split_ascii_whitespace();
let method = parts.next()?.to_ascii_uppercase();
let path = parts.next()?.to_owned();
return Some((method, path));
}
None
}
fn first_quoted_string(input: &str) -> Option<String> {
let trimmed = input.trim_start();
let quote = match trimmed.as_bytes().first()? {
b'\'' => '\'',
b'"' => '"',
_ => return None,
};
let rest = &trimmed[1..];
let end = rest.find(quote)?;
Some(rest[..end].to_owned())
}
fn http_method_name(method: crate::dynamic::framework::HttpMethod) -> &'static str {
match method {
crate::dynamic::framework::HttpMethod::GET => "GET",
crate::dynamic::framework::HttpMethod::HEAD => "HEAD",
crate::dynamic::framework::HttpMethod::POST => "POST",
crate::dynamic::framework::HttpMethod::PUT => "PUT",
crate::dynamic::framework::HttpMethod::PATCH => "PATCH",
crate::dynamic::framework::HttpMethod::DELETE => "DELETE",
crate::dynamic::framework::HttpMethod::OPTIONS => "OPTIONS",
}
}
fn invoke_for_shape(spec: &HarnessSpec, shape: RubyShape, entry_fn: &str) -> String {
match shape {
RubyShape::Generic => generic_invocation(spec, entry_fn),
RubyShape::SinatraRoute => format!(
r#" route = $nyx_sinatra_routes.find {{ |_, _, b| b }}
if route && route[2]
blk = route[2]
result = blk.call($nyx_payload)
print(result.to_s)
r#" _nyx_require_rack_mock
cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
app = if cls && cls.respond_to?(:call)
cls
elsif defined?(Sinatra) && Sinatra.const_defined?(:Application)
Sinatra::Application
end
if app
response = Rack::MockRequest.new(app).request(
($nyx_request[:method] || 'GET'),
_nyx_request_uri,
input: ($nyx_request[:body] || '')
)
print(response.body.to_s)
elsif respond_to?({entry_fn:?})
print(send({entry_fn:?}, $nyx_payload).to_s)
end"#,
cls = entry_class_from_spec(spec),
),
RubyShape::RailsAction => {
let cls = entry_class_from_spec(spec);
format!(
r#" _nyx_require_rack_mock
cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
if cls && cls.respond_to?(:action)
_status, _headers, body = cls.action({entry_fn:?}).call(_nyx_rack_env)
_nyx_print_rack_body(body)
end"#,
)
}
RubyShape::HanamiAction => {
let cls = entry_class_from_spec(spec);
format!(
r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
if cls
instance = cls.new
instance.instance_variable_set(:@__nyx_payload, $nyx_payload)
instance.instance_variable_set(:@__nyx_request, $nyx_request)
result = instance.send({entry_fn:?})
print(result.to_s) if result
action = cls.new
result = action.call(_nyx_rack_env)
if result.is_a?(Array) && result.length >= 3
_nyx_print_rack_body(result[2])
else
print(result.to_s) if result
end
end"#,
)
}
RubyShape::RackMiddleware => {
let cls = entry_class_from_spec(spec);
format!(
r#" require 'stringio'
cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil
if cls
inner = cls.respond_to?(:new) ? (cls.method(:new).arity == 0 ? cls.new : cls.new(nil)) : nil
env = {{
'REQUEST_METHOD' => ($nyx_request[:method] rescue 'GET'),
'PATH_INFO' => ($nyx_request[:path] rescue '/'),
'QUERY_STRING' => "payload=#{{$nyx_payload}}",
'rack.input' => StringIO.new(($nyx_request[:body] rescue '')),
'nyx.payload' => $nyx_payload,
}}
env = _nyx_rack_env
status, headers, body = inner.call(env)
Array(body).each {{ |chunk| print(chunk.to_s) }}
_nyx_print_rack_body(body)
end"#,
)
}
@ -2249,7 +2430,7 @@ fn parse_class_hosting_method(source: &str, entry_name: &str) -> Option<String>
if let Some(rest) = l.strip_prefix("class ") {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
.collect();
if !name.is_empty() {
last_class = Some(name);
@ -2269,7 +2450,7 @@ fn parse_first_class_name(source: &str) -> Option<String> {
if let Some(rest) = l.strip_prefix("class ") {
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == ':')
.collect();
if !name.is_empty() {
return Some(name);
@ -2391,6 +2572,13 @@ mod tests {
assert_eq!(RubyShape::detect(&spec, src), RubyShape::RailsAction);
}
#[test]
fn shape_detect_hanami_action() {
let src = "require 'hanami/action'\nclass RunAction < Hanami::Action\n def call(req)\n 'ok'\n end\nend\n";
let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb");
assert_eq!(RubyShape::detect(&spec, src), RubyShape::HanamiAction);
}
#[test]
fn shape_detect_rack_middleware() {
let src = "class MyMiddleware\n def call(env)\n [200, {}, ['ok']]\n end\nend\n";
@ -2413,26 +2601,56 @@ mod tests {
}
#[test]
fn sinatra_shape_uses_route_registry() {
fn sinatra_shape_uses_rack_request() {
let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb");
let src = generate_source(&spec, RubyShape::SinatraRoute);
assert!(src.contains("$nyx_sinatra_routes"));
assert!(src.contains("Rack::MockRequest.new(app).request"));
assert!(!src.contains("$nyx_sinatra_routes"));
}
#[test]
fn rack_shape_builds_env_hash() {
fn rack_shape_builds_env_through_rack() {
let mut spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb");
spec.payload_slot = PayloadSlot::QueryParam("payload".into());
let src = generate_source(&spec, RubyShape::RackMiddleware);
assert!(src.contains("REQUEST_METHOD"));
assert!(src.contains("rack.input"));
assert!(src.contains("Rack::MockRequest.env_for"));
assert!(src.contains("env['nyx.payload'] = $nyx_payload"));
}
#[test]
fn rails_shape_invokes_action_on_instance() {
fn rails_shape_invokes_action_rack_endpoint() {
let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb");
let src = generate_source(&spec, RubyShape::RailsAction);
assert!(src.contains("instance.send"));
assert!(src.contains("cls.action(\"index\").call(_nyx_rack_env)"));
}
#[test]
fn hanami_shape_invokes_action_with_rack_env() {
let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb");
let src = generate_source(&spec, RubyShape::HanamiAction);
assert!(src.contains("action.call(_nyx_rack_env)"));
}
#[test]
fn framework_shapes_stage_gemfile() {
let sinatra = extra_files_for_shape(RubyShape::SinatraRoute);
assert!(
sinatra
.iter()
.any(|(p, c)| p == "Gemfile" && c.contains("sinatra"))
);
let rails = extra_files_for_shape(RubyShape::RailsAction);
assert!(
rails
.iter()
.any(|(p, c)| p == "Gemfile" && c.contains("actionpack"))
);
let hanami = extra_files_for_shape(RubyShape::HanamiAction);
assert!(
hanami
.iter()
.any(|(p, c)| p == "Gemfile" && c.contains("hanami-controller"))
);
}
#[test]
@ -2452,6 +2670,10 @@ mod tests {
parse_first_class_name("class Bar < Base\nend\n"),
Some("Bar".to_owned())
);
assert_eq!(
parse_first_class_name("class Books::Show < Hanami::Action\nend\n"),
Some("Books::Show".to_owned())
);
assert_eq!(parse_first_class_name("def foo\nend\n"), None);
}

View file

@ -354,6 +354,27 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
return Err(RunError::BuildFailed { stderr, attempts });
}
}
Lang::Ruby => {
// bundle install if Gemfile is present.
match build_sandbox::prepare_ruby(spec, &harness.workdir) {
Ok(_) => {}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
Err(build_sandbox::BuildError::Io(e)) => {
return Err(RunError::BuildFailed {
stderr: format!("prepare ruby build cache: {e}"),
attempts: 1,
});
}
Err(build_sandbox::BuildError::Unsupported) => {
return Err(RunError::BuildFailed {
stderr: "ruby build preparation unsupported on this host".to_owned(),
attempts: 1,
});
}
}
}
Lang::C => {
// Compile the harness binary with `cc -o nyx_harness main.c`.
// Pass the sandbox profile so the build chooses `-static` when
@ -397,9 +418,6 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
Err(_) => {}
}
}
_ => {
// No build step for other languages.
}
}
trace_record(

View file

@ -132,6 +132,23 @@ pub static GATED_SINKS: &[SinkGate] = &[
object_destination_fields: &[],
},
},
// Output sinks: tainted values printed through a literal format string are
// not format-string vulnerabilities, but they still represent an
// attacker-controlled output flow in the real-world corpus.
SinkGate {
callee_matcher: "printf",
arg_index: 0,
dangerous_values: &[],
dangerous_prefixes: &[],
label: DataLabel::Sink(Cap::HTML_ESCAPE),
case_sensitive: false,
payload_args: crate::labels::ALL_ARGS_PAYLOAD,
keyword_name: None,
dangerous_kwargs: &[],
activation: GateActivation::Destination {
object_destination_fields: &[],
},
},
SinkGate {
callee_matcher: "fprintf",
arg_index: 1,

View file

@ -1611,6 +1611,64 @@ int main(void) {
);
}
#[test]
fn c_fgets_reaches_printf_data_arg() {
let src = br#"#include <stdio.h>
int main(void) {
char buf[256];
if (!fgets(buf, sizeof buf, stdin)) return 1;
printf("%s", buf);
return 0;
}
"#;
let lang = tree_sitter::Language::from(tree_sitter_c::LANGUAGE);
let file_cfg = parse_lang(src, "c", lang);
let findings = analyse_file(
&file_cfg,
&file_cfg.summaries,
None,
Lang::C,
"test.c",
&[],
None,
);
assert!(
findings
.iter()
.any(|f| f.source_kind == crate::labels::SourceKind::UserInput),
"C: fgets buffer should reach printf data arg, got {findings:#?}"
);
}
#[test]
fn c_gets_reaches_printf_data_arg() {
let src = br#"#include <stdio.h>
int main(void) {
char buf[256];
gets(buf);
printf("%s\n", buf);
return 0;
}
"#;
let lang = tree_sitter::Language::from(tree_sitter_c::LANGUAGE);
let file_cfg = parse_lang(src, "c", lang);
let findings = analyse_file(
&file_cfg,
&file_cfg.summaries,
None,
Lang::C,
"test.c",
&[],
None,
);
assert!(
findings
.iter()
.any(|f| f.source_kind == crate::labels::SourceKind::UserInput),
"C: gets buffer should reach printf data arg, got {findings:#?}"
);
}
#[test]
fn c_execvp_ignores_env_config_executable_path() {
let src = br#"#include <stdlib.h>

View file

@ -74,6 +74,10 @@ pub enum Prerequisite {
/// the resolution path skips with a structured reason instead of
/// failing the test.
NodeModuleAvailable(&'static str),
/// A Ruby feature must be loadable via `require`. Used by Ruby
/// framework-bound shape suites so hosts without preinstalled gems can
/// skip instead of depending on network access during tests.
RubyRequireAvailable(&'static str),
/// A binary must resolve on `PATH` and respond to `--version` with
/// exit code 0, but the binary name can be overridden via an env
/// var. Used by the C / C++ fixture suites where `cc` / `c++` can
@ -97,6 +101,7 @@ pub enum SkipReason {
DockerUnavailable,
MissingStaticLib(&'static str),
MissingNodeModule(&'static str),
MissingRubyRequire(&'static str),
}
impl std::fmt::Display for SkipReason {
@ -109,6 +114,7 @@ impl std::fmt::Display for SkipReason {
SkipReason::MissingNodeModule(m) => {
write!(f, "Node module not resolvable via require.resolve: {m}")
}
SkipReason::MissingRubyRequire(r) => write!(f, "Ruby feature not loadable: {r}"),
}
}
}
@ -178,6 +184,19 @@ pub fn check_prerequisites(reqs: &[Prerequisite]) -> Result<(), SkipReason> {
return Err(SkipReason::MissingNodeModule(name));
}
}
Prerequisite::RubyRequireAvailable(feature) => {
let script = "begin; require ARGV.fetch(0); rescue LoadError; exit 1; end";
let ok = std::process::Command::new("ruby")
.arg("-e")
.arg(script)
.arg(feature)
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !ok {
return Err(SkipReason::MissingRubyRequire(feature));
}
}
Prerequisite::StaticLib(lib) => {
// Treat the lib as linkable iff `cc -static -l<lib>` on
// an empty TU succeeds. Slow but reliable; only called

View file

@ -1,8 +1,5 @@
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'
# Hanami action fixture. The harness invokes the action with a Rack env.
gem 'hanami-controller'
gem 'rack'

View file

@ -1,13 +1,19 @@
# Phase 15 — Hanami Action.call, benign.
# Validates payload before running the fixed echo.
# Ruby Hanami Action.call, benign.
# Validates the real request parameter before running a fixed echo.
# nyx-shape: hanami
# nyx-route: GET /run
require 'hanami/action'
require 'rack/request'
class RunAction < Hanami::Action
def call(req)
payload = req && req.is_a?(Hash) ? (req['nyx.payload'] || '') : (ENV['NYX_PAYLOAD'] || '')
payload = if req.is_a?(Hash)
Rack::Request.new(req).params['payload'].to_s
elsif req.respond_to?(:params)
req.params['payload'].to_s
else
ENV['NYX_PAYLOAD'].to_s
end
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
return "invalid"

View file

@ -1,15 +1,21 @@
# Phase 15 — Hanami Action.call, vulnerable.
# Class includes Hanami::Action and exposes a `call` method that pipes
# the request body into /bin/sh.
# Ruby Hanami Action.call, vulnerable.
# The class imports Hanami::Action and reads the Rack request routed by
# the harness.
# nyx-shape: hanami
# nyx-route: GET /run
require 'hanami/action'
require 'rack/request'
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'] || '')
payload = if req.is_a?(Hash)
Rack::Request.new(req).params['payload'].to_s
elsif req.respond_to?(:params)
req.params['payload'].to_s
else
ENV['NYX_PAYLOAD'].to_s
end
out = `echo hello #{payload}`
STDOUT.print(out)
out

View file

@ -1,6 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Rack middleware shape. The harness constructs
# a Rack-shaped env hash and dispatches; the rack gem is not required
# at runtime because the env-hash invocation pattern is standalone.
# Rack middleware fixture. The harness builds the env through
# Rack::MockRequest before dispatching the middleware.
gem 'rack'

View file

@ -1,7 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Rails action shape. The harness instantiates
# the controller via .new and calls the action through reflection;
# the rails gem is not actually required at runtime. The Gemfile is
# informational so cargo-side fixture pickup sees a non-empty manifest.
gem 'rails'
# ActionController fixture. The harness calls the controller's Rack
# endpoint with Rack::MockRequest.
gem 'actionpack'

View file

@ -1,24 +1,21 @@
# Phase 15 — Rails-style controller action, benign.
# Ruby ActionController action, benign.
class ApplicationController
def initialize; end
require 'action_controller'
class ApplicationController < ActionController::Base
self.view_paths = []
end
class UsersController < ApplicationController
def initialize
super
@__nyx_payload = nil
@__nyx_request = nil
end
def index
payload = @__nyx_payload || ENV['NYX_PAYLOAD'] || ''
payload = params[:payload].to_s
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
return "invalid"
render plain: "invalid"
return
end
out = `echo hello`
STDOUT.print(out)
out
render plain: out
end
end

View file

@ -1,23 +1,18 @@
# Phase 15 — Rails-style controller action, vulnerable.
# Controller inherits the conventional ApplicationController name so
# RubyShape::detect picks RailsAction.
# Ruby ActionController action, vulnerable.
# The harness drives UsersController.action(:index) through Rack.
class ApplicationController
def initialize; end
require 'action_controller'
class ApplicationController < ActionController::Base
self.view_paths = []
end
class UsersController < ApplicationController
def initialize
super
@__nyx_payload = nil
@__nyx_request = nil
end
def index
STDOUT.print("__NYX_SINK_HIT__\n")
payload = @__nyx_payload || ENV['NYX_PAYLOAD'] || ''
payload = params[:payload].to_s
out = `echo hello #{payload}`
STDOUT.print(out)
out
render plain: out
end
end

View file

@ -1,6 +1,5 @@
source 'https://rubygems.org'
# Phase 15 fixture — Sinatra route shape. The harness emits its own
# route registry shim so the real sinatra gem is not required at
# runtime; the Gemfile is informational for cargo-side fixture pickup.
# Sinatra route fixture. The harness replays a Rack request through the
# real Sinatra app class.
gem 'sinatra'

View file

@ -1,13 +1,20 @@
# Phase 15 — Sinatra route, benign.
# Validates payload then runs a fixed echo.
# Ruby Sinatra route, benign.
# Validates the real path-capture parameter before running a fixed echo.
# nyx-shape: sinatra
get '/run' do |payload|
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
next "invalid"
require 'sinatra/base'
class NyxSinatraApp < Sinatra::Base
set :environment, :test
disable :run
get '/run/:payload' do |payload|
unless payload =~ /\A[A-Za-z0-9]{1,32}\z/
STDOUT.print("invalid\n")
"invalid"
else
out = `echo hello`
STDOUT.print(out)
out
end
end
out = `echo hello`
STDOUT.print(out)
out
end

View file

@ -1,11 +1,16 @@
# Phase 15 — Sinatra route, vulnerable.
# Reads payload (passed by harness via block argument) and pipes through /bin/sh.
# Entry: route block Cap: CODE_EXEC
# Ruby Sinatra route, vulnerable.
# Reads a real path-capture parameter from Sinatra and pipes it through /bin/sh.
# nyx-shape: sinatra
get '/run' do |payload|
STDOUT.print("__NYX_SINK_HIT__\n")
out = `echo hello #{payload}`
STDOUT.print(out)
out
require 'sinatra/base'
class NyxSinatraApp < Sinatra::Base
set :environment, :test
disable :run
get '/run/:payload' do |payload|
STDOUT.print("__NYX_SINK_HIT__\n")
out = `echo hello #{payload}`
STDOUT.print(out)
out
end
end

View file

@ -58,8 +58,28 @@ mod phase15_shape_tests {
// Phase 29 (Track I): structured prerequisite gating replaces
// the bespoke `ruby_available()` + per-test
// `eprintln!("SKIP ..."); return;` pattern.
let mut requires = vec![Prerequisite::CommandAvailable("ruby")];
match shape {
"sinatra_route" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("sinatra/base"));
}
"rails_action" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("action_controller"));
}
"hanami_action" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("hanami/action"));
}
"rack_middleware" => {
requires.push(Prerequisite::CommandAvailable("bundle"));
requires.push(Prerequisite::RubyRequireAvailable("rack/mock"));
}
_ => {}
}
run_shape_fixture_lang_or_skip(
&[Prerequisite::CommandAvailable("ruby")],
&requires,
Lang::Ruby,
"ruby",
shape,
@ -81,7 +101,7 @@ mod phase15_shape_tests {
"vuln.rb",
"run",
Cap::CODE_EXEC,
7,
12,
EntryKind::HttpRoute,
PayloadSlot::Param(0),
) else {
@ -97,7 +117,7 @@ mod phase15_shape_tests {
"benign.rb",
"run",
Cap::CODE_EXEC,
10,
15,
EntryKind::HttpRoute,
PayloadSlot::Param(0),
) else {
@ -115,7 +135,7 @@ mod phase15_shape_tests {
"vuln.rb",
"index",
Cap::CODE_EXEC,
17,
14,
EntryKind::HttpRoute,
PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
) else {
@ -131,7 +151,7 @@ mod phase15_shape_tests {
"benign.rb",
"index",
Cap::CODE_EXEC,
20,
17,
EntryKind::HttpRoute,
PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
) else {
@ -140,6 +160,40 @@ mod phase15_shape_tests {
assert_not_confirmed("rails_action", &r);
}
// ── hanami_action ───────────────────────────────────────────────────────
#[test]
fn hanami_action_vuln_is_confirmed() {
let Some(r) = run(
"hanami_action",
"vuln.rb",
"call",
Cap::CODE_EXEC,
19,
EntryKind::HttpRoute,
PayloadSlot::QueryParam("payload".into()),
) else {
return;
};
assert_confirmed("hanami_action", &r);
}
#[test]
fn hanami_action_benign_not_confirmed() {
let Some(r) = run(
"hanami_action",
"benign.rb",
"call",
Cap::CODE_EXEC,
21,
EntryKind::HttpRoute,
PayloadSlot::QueryParam("payload".into()),
) else {
return;
};
assert_not_confirmed("hanami_action", &r);
}
// ── rack_middleware ──────────────────────────────────────────────────────
#[test]
@ -149,7 +203,7 @@ mod phase15_shape_tests {
"vuln.rb",
"call",
Cap::CODE_EXEC,
9,
10,
EntryKind::HttpRoute,
PayloadSlot::EnvVar("NYX_PAYLOAD".into()),
) else {

View file

@ -92,13 +92,16 @@ fn sinatra_vuln_fixture_binds_route() {
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");
assert_eq!(route.path, "/run/:payload");
let payload_binding = binding
.request_params
.iter()
.find(|p| p.name == "payload")
.expect("payload block param");
assert!(matches!(payload_binding.source, ParamSource::QueryParam(_)));
.expect("payload path param");
assert!(matches!(
payload_binding.source,
ParamSource::PathSegment(_)
));
}
#[test]
@ -111,7 +114,7 @@ fn sinatra_benign_fixture_binds_same_route_shape() {
.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");
assert_eq!(route.path, "/run/:payload");
}
// ── Hanami ───────────────────────────────────────────────────────────────────

View file

@ -282,14 +282,14 @@ fn phase_15_ruby_route_findings_derive_specs_without_failure() {
(
"tests/dynamic_fixtures/ruby/rails_action/vuln.rb",
"index",
19,
14,
Cap::SHELL_ESCAPE,
"rb.cmdi.backtick",
),
(
"tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb",
"run",
9,
12,
Cap::SHELL_ESCAPE,
"rb.cmdi.backtick",
),
@ -310,7 +310,7 @@ fn phase_15_ruby_route_findings_derive_specs_without_failure() {
(
"tests/dynamic_fixtures/ruby/hanami_action/vuln.rb",
"call",
13,
19,
Cap::SHELL_ESCAPE,
"rb.cmdi.backtick",
),