mirror of
https://github.com/elicpeter/nyx.git
synced 2026-06-09 19:45:13 +02:00
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:
parent
41c7b73575
commit
aaf49acefb
20 changed files with 773 additions and 218 deletions
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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 ¶m.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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue