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>