[pitboss] phase 05: M5 — JS/TS, Go, Java, PHP harness emitters

This commit is contained in:
pitboss 2026-05-12 02:20:55 -04:00
parent 84638e7d57
commit 345b44d3cc
103 changed files with 5637 additions and 34 deletions

View file

@ -322,6 +322,377 @@ fn build_cache_path(
Ok(path)
}
// ── Node.js build sandbox ─────────────────────────────────────────────────────
/// Prepare a Node.js project for `spec` in `workdir`.
///
/// Runs `npm install --no-save` if `package.json` is present.
/// Build isolation is NOT yet implemented (deferred to a future phase).
/// npm lifecycle scripts run on the host. See deferred.md for details.
pub fn prepare_node(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_node_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "node", &spec.toolchain_id)?;
// Cache hit: node_modules already installed.
if cache_path.join(".node_cache_done").exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
// No package.json = no deps to install.
if !workdir.join("package.json").exists() {
std::fs::write(cache_path.join(".node_cache_done"), b"no-package-json")?;
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_npm_install(workdir) {
Ok(()) => {
let _ = std::fs::write(cache_path.join(".node_cache_done"), b"done");
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_npm_install(workdir: &Path) -> Result<(), String> {
let npm = std::env::var("NYX_NPM_BIN").unwrap_or_else(|_| "npm".to_owned());
let output = Command::new(&npm)
.args(["install", "--no-save", "--no-audit", "--no-fund"])
.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!("npm install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn compute_node_lockfile_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"] {
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()))
}
// ── Go build sandbox ──────────────────────────────────────────────────────────
/// Prepare a compiled Go binary for `spec`.
///
/// Checks a build cache keyed on `(go.mod + go.sum + entry hash, "go", toolchain_id)`.
/// On a cache hit returns immediately; otherwise runs `go build -o nyx_harness .`
/// in `workdir`.
///
/// Build isolation is NOT yet implemented (deferred). `go build` runs on the
/// host. A malicious `init()` therefore runs with host privileges. See deferred.md.
pub fn prepare_go(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_go_source_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "go", &spec.toolchain_id)?;
let binary = cache_path.join("nyx_harness");
if binary.exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
let _ = std::fs::remove_dir_all(&cache_path);
std::fs::create_dir_all(&cache_path)?;
match try_build_go_binary(workdir, &binary) {
Ok(()) => {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
let _ = std::fs::remove_file(&binary);
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_build_go_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> {
let go_bin = std::env::var("NYX_GO_BIN").unwrap_or_else(|_| "go".to_owned());
let output = Command::new(&go_bin)
.args(["build", "-o", binary_dest.to_str().unwrap_or("nyx_harness"), "."])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("GOPATH", std::env::var("GOPATH").unwrap_or_else(|_| {
std::env::var("HOME").map(|h| format!("{h}/go")).unwrap_or_else(|_| "/tmp/go".to_owned())
}))
.env("GOMODCACHE", std::env::var("GOMODCACHE").unwrap_or_else(|_| {
std::env::var("HOME").map(|h| format!("{h}/go/pkg/mod")).unwrap_or_else(|_| "/tmp/gomod".to_owned())
}))
.output()
.map_err(|e| format!("go build: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn compute_go_source_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["go.mod", "go.sum", "main.go"] {
if let Ok(content) = std::fs::read(workdir.join(fname)) {
h.update(fname.as_bytes());
h.update(&content);
}
}
if let Ok(content) = std::fs::read(workdir.join("entry").join("entry.go")) {
h.update(b"entry/entry.go");
h.update(&content);
}
let out = h.finalize();
format!("{:016x}", u64::from_le_bytes(out.as_bytes()[..8].try_into().unwrap()))
}
// ── Java build sandbox ────────────────────────────────────────────────────────
/// Prepare compiled Java classes for `spec`.
///
/// Runs `javac NyxHarness.java Entry.java` in `workdir`.
/// Class files land in the workdir (default package, no output dir).
///
/// Build isolation is NOT yet implemented (deferred). `javac` runs on the host.
/// A malicious annotation processor / compile-time plugin could run with host
/// privileges. See deferred.md for planned `nyx-build-java:{toolchain_id}` container.
pub fn prepare_java(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let source_hash = compute_java_source_hash(workdir);
let cache_path = build_cache_path(&source_hash, "java", &spec.toolchain_id)?;
// Cache hit: class files already compiled. Restore them 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 &["NyxHarness.class", "Entry.class"] {
let src = cache_path.join(cls);
let dst = workdir.join(cls);
if src.exists() && !dst.exists() {
let _ = std::fs::copy(&src, &dst);
}
}
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_compile_java(workdir, &cache_path) {
Ok(()) => {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
let _ = std::fs::remove_file(cache_path.join("NyxHarness.class"));
let _ = std::fs::remove_file(cache_path.join("Entry.class"));
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_compile_java(workdir: &Path, cache_path: &Path) -> Result<(), String> {
let javac = std::env::var("NYX_JAVAC_BIN").unwrap_or_else(|_| "javac".to_owned());
// Compile sources — class files are written to workdir by default.
let mut args = vec!["-d".to_owned(), workdir.to_string_lossy().into_owned()];
for src in &["NyxHarness.java", "Entry.java"] {
let p = workdir.join(src);
if p.exists() {
args.push(p.to_string_lossy().into_owned());
}
}
let output = Command::new(&javac)
.args(&args)
.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!("javac: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
// Copy class files to cache.
for cls in &["NyxHarness.class", "Entry.class"] {
let src = workdir.join(cls);
if src.exists() {
let _ = std::fs::copy(&src, cache_path.join(cls));
}
}
Ok(())
}
fn compute_java_source_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["NyxHarness.java", "Entry.java"] {
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()))
}
// ── PHP build sandbox ─────────────────────────────────────────────────────────
/// Prepare a PHP project for `spec` in `workdir`.
///
/// Runs `composer install --no-interaction` if `composer.json` is present.
/// Build isolation is NOT yet implemented (deferred). Composer post-install
/// scripts run on the host. See deferred.md for planned
/// `nyx-build-php:{toolchain_id}` container details.
pub fn prepare_php(spec: &HarnessSpec, workdir: &Path) -> Result<BuildResult, BuildError> {
let lockfile_hash = compute_php_lockfile_hash(workdir);
let cache_path = build_cache_path(&lockfile_hash, "php", &spec.toolchain_id)?;
if cache_path.join(".php_cache_done").exists() {
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: true,
duration: std::time::Duration::ZERO,
});
}
if !workdir.join("composer.json").exists() {
std::fs::write(cache_path.join(".php_cache_done"), b"no-composer-json")?;
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: std::time::Duration::ZERO,
});
}
let start = std::time::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(std::time::Duration::from_secs(BACKOFF[attempt as usize - 1]));
}
match try_composer_install(workdir) {
Ok(()) => {
let _ = std::fs::write(cache_path.join(".php_cache_done"), b"done");
return Ok(BuildResult {
venv_path: cache_path,
cache_hit: false,
duration: start.elapsed(),
});
}
Err(e) => {
last_err = e;
}
}
}
Err(BuildError::BuildFailed { stderr: last_err, attempts: MAX_ATTEMPTS })
}
fn try_composer_install(workdir: &Path) -> Result<(), String> {
let composer = std::env::var("NYX_COMPOSER_BIN").unwrap_or_else(|_| "composer".to_owned());
let output = Command::new(&composer)
.args(["install", "--no-interaction", "--no-dev", "--prefer-dist"])
.current_dir(workdir)
.env_clear()
.env("PATH", std::env::var("PATH").unwrap_or_default())
.env("HOME", std::env::var("HOME").unwrap_or_default())
.env("COMPOSER_ALLOW_SUPERUSER", "1")
.output()
.map_err(|e| format!("composer install: {e}"))?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).into_owned());
}
Ok(())
}
fn compute_php_lockfile_hash(workdir: &Path) -> String {
let mut h = Hasher::new();
for fname in &["composer.json", "composer.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()))
}
#[cfg(test)]
mod tests {
use super::*;
@ -342,4 +713,29 @@ mod tests {
let h2 = compute_lockfile_hash(dir.path());
assert_ne!(h1, h2, "hash must change when requirements.txt changes");
}
#[test]
fn node_lockfile_hash_stable() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_node_lockfile_hash(dir.path());
let h2 = compute_node_lockfile_hash(dir.path());
assert_eq!(h1, h2);
}
#[test]
fn go_source_hash_changes_with_main_go() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_go_source_hash(dir.path());
std::fs::write(dir.path().join("main.go"), "package main\nfunc main() {}").unwrap();
let h2 = compute_go_source_hash(dir.path());
assert_ne!(h1, h2);
}
#[test]
fn java_source_hash_stable() {
let dir = tempfile::TempDir::new().unwrap();
let h1 = compute_java_source_hash(dir.path());
let h2 = compute_java_source_hash(dir.path());
assert_eq!(h1, h2);
}
}

View file

@ -177,18 +177,18 @@ mod tests {
#[test]
fn build_unsupported_lang_returns_err() {
// Go is not yet supported (unsupported lang path).
// C is not supported (no emitter exists for it).
let spec = HarnessSpec {
finding_id: "0000000000000001".into(),
entry_file: "main.go".into(),
entry_file: "main.c".into(),
entry_name: "handleRequest".into(),
entry_kind: EntryKind::Function,
lang: Lang::Go,
toolchain_id: "go-stable".into(),
lang: Lang::C,
toolchain_id: "c-stable".into(),
payload_slot: PayloadSlot::Param(0),
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "main.go".into(),
sink_file: "main.c".into(),
sink_line: 5,
spec_hash: "0000000000000000".into(),
};

219
src/dynamic/lang/go.rs Normal file
View file

@ -0,0 +1,219 @@
//! Go harness emitter.
//!
//! Generates a Go `main` package that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Imports the entry package from `./entry/` and calls the entry function.
//! 3. Uses `runtime.Caller`-style wrapping in fixtures for sink-reachability
//! probes (fixtures explicitly emit `__NYX_SINK_HIT__` before the sink).
//!
//! Build step: `prepare_go()` in `build_sandbox.rs` runs `go build -o nyx_harness .`
//! in the workdir. The harness command is updated to the compiled binary path.
//!
//! File layout in workdir:
//! ```text
//! main.go ← harness entry point (generated)
//! go.mod ← module definition (generated)
//! entry/
//! entry.go ← entry function (copied from project; must have `package entry`)
//! ```
//!
//! Payload slot support:
//! - `PayloadSlot::Param(0)` — pass payload as `string` first argument.
//! - `PayloadSlot::EnvVar(name)` — set env var before calling entry.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a Go harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {}
_ => return Err(UnsupportedReason::EntryKindUnsupported),
}
let main_go = generate_main_go(spec);
let go_mod = generate_go_mod();
Ok(HarnessSource {
source: main_go,
filename: "main.go".to_owned(),
command: vec!["./nyx_harness".to_owned()],
extra_files: vec![("go.mod".to_owned(), go_mod)],
entry_subpath: Some("entry/entry.go".to_owned()),
})
}
fn generate_main_go(spec: &HarnessSpec) -> String {
let entry_fn = capitalize_first(&spec.entry_name);
let (pre_call, call_expr) = build_call(spec, &entry_fn);
// Determine which imports are needed.
let env_import = if matches!(&spec.payload_slot, PayloadSlot::EnvVar(_)) {
""
} else {
""
};
let _ = env_import;
format!(
r#"// Nyx dynamic harness — auto-generated, do not edit.
package main
import (
"encoding/base64"
"fmt"
"os"
"nyx-harness/entry"
)
func main() {{
payload := nyxPayload()
{pre_call} {call_expr}
_ = fmt.Sprintf("") // suppress unused import if call_expr uses fmt directly
_ = os.Stderr // suppress unused import
}}
func nyxPayload() string {{
if v := os.Getenv("NYX_PAYLOAD"); v != "" {{
return v
}}
if b64 := os.Getenv("NYX_PAYLOAD_B64"); b64 != "" {{
if data, err := base64.StdEncoding.DecodeString(b64); err == nil {{
return string(data)
}}
}}
return ""
}}
"#,
pre_call = pre_call,
call_expr = call_expr,
)
}
fn generate_go_mod() -> String {
"module nyx-harness\n\ngo 1.21\n".to_owned()
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, entry_fn: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(0) => {
let pre = String::new();
let call = format!("entry.{entry_fn}(payload)");
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("\tos.Setenv({name:?}, payload)\n");
let call = format!("entry.{entry_fn}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("entry.{entry_fn}(payload)");
(pre, call)
}
}
}
/// Capitalize the first character of a string (Go exported names must start uppercase).
pub fn capitalize_first(s: &str) -> String {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().collect::<String>() + c.as_str(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::labels::Cap;
use crate::symbol::Lang;
fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec {
HarnessSpec {
finding_id: "go0000000000001".into(),
entry_file: "cmd/server/main.go".into(),
entry_name: "handleRequest".into(),
entry_kind: EntryKind::Function,
lang: Lang::Go,
toolchain_id: "go-stable".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "cmd/server/main.go".into(),
sink_line: 20,
spec_hash: "go0000000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("nyx-harness/entry"));
assert!(harness.source.contains("nyxPayload()"));
assert!(harness.source.contains("entry.HandleRequest(payload)"));
assert_eq!(harness.filename, "main.go");
assert_eq!(harness.command, vec!["./nyx_harness"]);
}
#[test]
fn emit_includes_go_mod_in_extra_files() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
let go_mod = harness.extra_files.iter().find(|(n, _)| n == "go.mod");
assert!(go_mod.is_some(), "go.mod must be in extra_files");
assert!(go_mod.unwrap().1.contains("module nyx-harness"));
}
#[test]
fn emit_entry_subpath_is_entry_go() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("entry/entry.go".to_owned()));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_USER".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("os.Setenv"));
assert!(harness.source.contains("\"DB_USER\""));
}
#[test]
fn emit_param_gt_0_is_unsupported() {
let spec = make_spec(PayloadSlot::Param(1));
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn emit_stdin_is_unsupported() {
let spec = make_spec(PayloadSlot::Stdin);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn capitalize_first_handles_lowercase() {
assert_eq!(capitalize_first("handleRequest"), "HandleRequest");
assert_eq!(capitalize_first("run"), "Run");
assert_eq!(capitalize_first(""), "");
assert_eq!(capitalize_first("A"), "A");
}
#[test]
fn go_mod_has_correct_module() {
let go_mod = generate_go_mod();
assert!(go_mod.contains("module nyx-harness"));
assert!(go_mod.contains("go 1.21"));
}
}

191
src/dynamic/lang/java.rs Normal file
View file

@ -0,0 +1,191 @@
//! Java harness emitter.
//!
//! Generates a Java `NyxHarness.java` that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Calls `Entry.{entry_name}(payload)` from the co-located `Entry.java`.
//! 3. Catches all exceptions to prevent harness crashes from masking results.
//!
//! Sink-reachability probe: fixtures explicitly emit `System.out.println("__NYX_SINK_HIT__")`
//! before the actual sink call (same pattern as Rust and Go fixtures).
//!
//! Build step: `prepare_java()` in `build_sandbox.rs` runs `javac NyxHarness.java Entry.java`
//! in the workdir. The compiled `.class` files land in the workdir.
//!
//! File layout in workdir:
//! ```text
//! NyxHarness.java ← harness main class (generated)
//! Entry.java ← entry class (copied from project)
//! NyxHarness.class ← compiled by prepare_java()
//! Entry.class ← compiled by prepare_java()
//! ```
//!
//! Payload slot support:
//! - `PayloadSlot::Param(0)` — pass payload as `String` first argument.
//! - `PayloadSlot::EnvVar(name)` — set system property before calling entry.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a Java harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {}
_ => return Err(UnsupportedReason::EntryKindUnsupported),
}
let source = generate_harness_java(spec);
Ok(HarnessSource {
source,
filename: "NyxHarness.java".to_owned(),
// Use absolute workdir classpath set by runner.rs after compilation.
// Before runner.rs updates it, '.' works for process backend when run
// from the workdir.
command: vec![
"java".to_owned(),
"-cp".to_owned(),
".".to_owned(),
"NyxHarness".to_owned(),
],
extra_files: vec![],
entry_subpath: Some("Entry.java".to_owned()),
})
}
fn generate_harness_java(spec: &HarnessSpec) -> String {
let entry_method = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, entry_method);
format!(
r#"// Nyx dynamic harness — auto-generated, do not edit.
public class NyxHarness {{
public static void main(String[] args) throws Exception {{
String payload = nyxPayload();
{pre_call} try {{
{call_expr}
}} catch (Exception e) {{
System.err.println("NYX_EXCEPTION: " + e.getClass().getName() + ": " + e.getMessage());
}}
}}
static String nyxPayload() {{
String v = System.getenv("NYX_PAYLOAD");
if (v != null && !v.isEmpty()) {{
return v;
}}
String b64 = System.getenv("NYX_PAYLOAD_B64");
if (b64 != null && !b64.isEmpty()) {{
byte[] decoded = java.util.Base64.getDecoder().decode(b64);
return new String(decoded, java.nio.charset.StandardCharsets.UTF_8);
}}
return "";
}}
}}
"#,
pre_call = pre_call,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, method: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(0) => {
let pre = String::new();
let call = format!("Entry.{method}(payload);");
(pre, call)
}
PayloadSlot::EnvVar(name) => {
// Use System.setProperty since env vars cannot be set post-JVM-launch
// via standard Java APIs. Fixtures that read env vars must use
// System.getProperty as a fallback, or read NYX_PAYLOAD_PROP_{name}.
let pre = format!(
" System.setProperty({name:?}, payload);\n"
);
let call = format!("Entry.{method}();");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("Entry.{method}(payload);");
(pre, call)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::labels::Cap;
use crate::symbol::Lang;
fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec {
HarnessSpec {
finding_id: "java00000000001".into(),
entry_file: "src/main/java/App.java".into(),
entry_name: "processInput".into(),
entry_kind: EntryKind::Function,
lang: Lang::Java,
toolchain_id: "java-21".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "src/main/java/App.java".into(),
sink_line: 25,
spec_hash: "java00000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("public class NyxHarness"));
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"]);
}
#[test]
fn emit_entry_subpath_is_entry_java() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("Entry.java".to_owned()));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_PASSWORD".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("System.setProperty"));
assert!(harness.source.contains("\"DB_PASSWORD\""));
}
#[test]
fn emit_param_gt_0_is_unsupported() {
let spec = make_spec(PayloadSlot::Param(1));
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn emit_stdin_is_unsupported() {
let spec = make_spec(PayloadSlot::Stdin);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn harness_has_base64_decoder() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("Base64.getDecoder()"));
assert!(harness.source.contains("NYX_PAYLOAD_B64"));
}
}

View file

@ -0,0 +1,248 @@
//! JavaScript / TypeScript harness emitter.
//!
//! Generates a Node.js script that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Requires the entry module from the workdir (`entry.js`).
//! 3. Calls the entry function with the payload routed to the correct slot.
//! 4. Catches all exceptions to prevent harness crashes from masking results.
//!
//! Sink-reachability probe: the fixture itself emits `__NYX_SINK_HIT__` before
//! the actual sink call (same pattern as Rust fixtures). The harness is a pure
//! runner with no line-level tracing.
//!
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set env var before calling.
//! - `PayloadSlot::Stdin` — pipe payload to process.stdin.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build: no compilation step. Command is `node harness.js`.
//! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a Node.js harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {}
_ => return Err(UnsupportedReason::EntryKindUnsupported),
}
let source = generate_source(spec);
let entry_filename = entry_module_filename(&spec.entry_file);
Ok(HarnessSource {
source,
filename: "harness.js".to_owned(),
command: vec!["node".to_owned(), "harness.js".to_owned()],
extra_files: vec![],
entry_subpath: Some(entry_filename),
})
}
fn generate_source(spec: &HarnessSpec) -> String {
let entry_module = entry_module_name(&spec.entry_file);
let entry_fn = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, &entry_module, entry_fn);
format!(
r#"'use strict';
// Nyx dynamic harness — auto-generated, do not edit.
// ── Payload loading ────────────────────────────────────────────────────────────
const _nyx_payload = (() => {{
if (process.env.NYX_PAYLOAD && process.env.NYX_PAYLOAD.length > 0) {{
return process.env.NYX_PAYLOAD;
}}
if (process.env.NYX_PAYLOAD_B64 && process.env.NYX_PAYLOAD_B64.length > 0) {{
return Buffer.from(process.env.NYX_PAYLOAD_B64, 'base64').toString('utf8');
}}
return '';
}})();
// ── Entry module import ────────────────────────────────────────────────────────
let _entry;
try {{
_entry = require('./{entry_module}');
}} catch (e) {{
process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n');
process.exit(77);
}}
const payload = _nyx_payload;
// ── Pre-call setup ─────────────────────────────────────────────────────────────
{pre_call}
// ── Call entry point ──────────────────────────────────────────────────────────
try {{
const _result = {call_expr};
if (_result !== undefined && _result !== null) {{
if (_result && typeof _result.then === 'function') {{
_result
.then(r => {{ if (r != null) process.stdout.write(String(r) + '\n'); }})
.catch(e => {{ process.stderr.write('NYX_EXCEPTION: ' + e.message + '\n'); }});
}} else {{
process.stdout.write(String(_result) + '\n');
}}
}}
}} catch (e) {{
process.stderr.write('NYX_EXCEPTION: ' + (e.constructor ? e.constructor.name : 'Error') + ': ' + e.message + '\n');
}}
"#,
entry_module = entry_module,
pre_call = pre_call,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, _module: &str, func: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(idx) => {
let pre = String::new();
let call = if *idx == 0 {
format!("_entry.{func}(payload)")
} else {
let pads = (0..*idx).map(|_| "''").collect::<Vec<_>>().join(", ");
format!("_entry.{func}({pads}, payload)")
};
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("process.env[{name:?}] = payload;\n");
let call = format!("_entry.{func}()");
(pre, call)
}
PayloadSlot::Stdin => {
// Synchronous stdin replacement via Buffer.
let pre = format!(
"const {{ Readable }} = require('stream');\n\
process.stdin = Readable.from([Buffer.from(payload, 'utf8')]);\n"
);
let call = format!("_entry.{func}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("_entry.{func}(payload)");
(pre, call)
}
}
}
/// Derive the JS module name from an entry file path.
///
/// `"src/handlers/login.js"` → `"login"` (basename without extension).
pub fn entry_module_name(entry_file: &str) -> String {
let base = entry_file
.rsplit('/')
.next()
.unwrap_or(entry_file)
.rsplit('\\')
.next()
.unwrap_or(entry_file);
// Strip known JS/TS extensions.
for ext in &[".js", ".mjs", ".cjs", ".ts", ".mts"] {
if let Some(stem) = base.strip_suffix(ext) {
return stem.to_owned();
}
}
base.to_owned()
}
/// Derive the filename for `entry_subpath` from an entry file path.
///
/// Always returns `"entry.js"` — fixture files are copied here regardless of
/// their original name so the harness can always `require('./entry')`.
pub fn entry_module_filename(_entry_file: &str) -> String {
"entry.js".to_owned()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::labels::Cap;
use crate::symbol::Lang;
fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec {
HarnessSpec {
finding_id: "js000000000001".into(),
entry_file: "src/app.js".into(),
entry_name: "login".into(),
entry_kind: EntryKind::Function,
lang: Lang::JavaScript,
toolchain_id: "node-20".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "src/app.js".into(),
sink_line: 15,
spec_hash: "js000000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("NYX_PAYLOAD"));
assert!(harness.source.contains("require"));
assert!(harness.source.contains("login"));
assert_eq!(harness.filename, "harness.js");
assert_eq!(harness.command, vec!["node", "harness.js"]);
}
#[test]
fn emit_param_index_0() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("_entry.login(payload)"));
}
#[test]
fn emit_param_index_1() {
let spec = make_spec(PayloadSlot::Param(1));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("_entry.login('', payload)"));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("process.env[\"DB_HOST\"] = payload"));
}
#[test]
fn emit_stdin_slot() {
let spec = make_spec(PayloadSlot::Stdin);
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("Readable"));
assert!(harness.source.contains("process.stdin"));
}
#[test]
fn emit_http_body_is_unsupported() {
let spec = make_spec(PayloadSlot::HttpBody);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn emit_entry_subpath_is_entry_js() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("entry.js".to_owned()));
}
#[test]
fn entry_module_name_strips_extensions() {
assert_eq!(entry_module_name("src/handlers/login.js"), "login");
assert_eq!(entry_module_name("app.ts"), "app");
assert_eq!(entry_module_name("handler.mjs"), "handler");
assert_eq!(entry_module_name("no_ext"), "no_ext");
}
}

View file

@ -3,6 +3,10 @@
//! Each submodule implements `emit(spec) -> HarnessSource` for one language.
//! The top-level [`emit`] function dispatches on `spec.lang`.
pub mod go;
pub mod java;
pub mod javascript;
pub mod php;
pub mod python;
pub mod rust;
@ -34,6 +38,10 @@ pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match spec.lang {
Lang::Python => python::emit(spec),
Lang::Rust => rust::emit(spec),
Lang::JavaScript | Lang::TypeScript => javascript::emit(spec),
Lang::Go => go::emit(spec),
Lang::Java => java::emit(spec),
Lang::Php => php::emit(spec),
_ => Err(UnsupportedReason::LangUnsupported),
}
}

202
src/dynamic/lang/php.rs Normal file
View file

@ -0,0 +1,202 @@
//! PHP harness emitter.
//!
//! Generates a PHP script that:
//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars.
//! 2. Includes the entry file (`entry.php`) from the workdir.
//! 3. Calls the entry function with the payload routed to the correct slot.
//! 4. Catches all Throwables to prevent harness crashes from masking results.
//!
//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` before
//! the actual sink call (same pattern as Rust / JS fixtures).
//!
//! Payload slot support:
//! - `PayloadSlot::Param(n)` — n-th positional argument.
//! - `PayloadSlot::EnvVar(name)` — set `$_ENV`/`putenv()` before calling.
//! - `PayloadSlot::Stdin` — wrap `STDIN` with the payload.
//! - Other slots produce `UnsupportedReason::EntryKindUnsupported`.
//!
//! Build: no compilation step. Command is `php harness.php`.
//! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1).
use crate::dynamic::lang::HarnessSource;
use crate::dynamic::spec::{HarnessSpec, PayloadSlot};
use crate::evidence::UnsupportedReason;
/// Emit a PHP harness for `spec`.
pub fn emit(spec: &HarnessSpec) -> Result<HarnessSource, UnsupportedReason> {
match &spec.payload_slot {
PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {}
_ => return Err(UnsupportedReason::EntryKindUnsupported),
}
let source = generate_source(spec);
Ok(HarnessSource {
source,
filename: "harness.php".to_owned(),
command: vec!["php".to_owned(), "harness.php".to_owned()],
extra_files: vec![],
entry_subpath: Some("entry.php".to_owned()),
})
}
fn generate_source(spec: &HarnessSpec) -> String {
let entry_fn = &spec.entry_name;
let (pre_call, call_expr) = build_call(spec, entry_fn);
format!(
r#"<?php
// Nyx dynamic harness — auto-generated, do not edit.
// ── Payload loading ────────────────────────────────────────────────────────────
function nyx_payload(): string {{
$v = getenv('NYX_PAYLOAD');
if ($v !== false && $v !== '') {{
return $v;
}}
$b64 = getenv('NYX_PAYLOAD_B64');
if ($b64 !== false && $b64 !== '') {{
return base64_decode($b64, true) ?: '';
}}
return '';
}}
$payload = nyx_payload();
// ── Entry include ─────────────────────────────────────────────────────────────
try {{
require_once __DIR__ . '/entry.php';
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_IMPORT_ERROR: ' . $e->getMessage() . "\n");
exit(77);
}}
// ── Pre-call setup ─────────────────────────────────────────────────────────────
{pre_call}
// ── Call entry point ──────────────────────────────────────────────────────────
try {{
$result = {call_expr};
if ($result !== null) {{
echo $result . "\n";
}}
}} catch (Throwable $e) {{
fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n");
}}
"#,
pre_call = pre_call,
call_expr = call_expr,
)
}
/// Build `(pre_call_setup, call_expression)` for the chosen payload slot.
fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) {
match &spec.payload_slot {
PayloadSlot::Param(idx) => {
let pre = String::new();
let call = if *idx == 0 {
format!("{func}($payload)")
} else {
let pads = (0..*idx).map(|_| "''").collect::<Vec<_>>().join(", ");
format!("{func}({pads}, $payload)")
};
(pre, call)
}
PayloadSlot::EnvVar(name) => {
let pre = format!("putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n");
let call = format!("{func}()");
(pre, call)
}
PayloadSlot::Stdin => {
// Replace STDIN with an in-memory stream containing the payload.
let pre = "if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n // Note: STDIN reassignment is not portable; fixture reads via fgets(STDIN).\n}\n".to_owned();
let call = format!("{func}()");
(pre, call)
}
_ => {
let pre = String::new();
let call = format!("{func}($payload)");
(pre, call)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot};
use crate::labels::Cap;
use crate::symbol::Lang;
fn make_spec(payload_slot: PayloadSlot) -> HarnessSpec {
HarnessSpec {
finding_id: "php0000000000001".into(),
entry_file: "src/login.php".into(),
entry_name: "login".into(),
entry_kind: EntryKind::Function,
lang: Lang::Php,
toolchain_id: "php-8".into(),
payload_slot,
expected_cap: Cap::SQL_QUERY,
constraint_hints: vec![],
sink_file: "src/login.php".into(),
sink_line: 10,
spec_hash: "php0000000000001".into(),
}
}
#[test]
fn emit_produces_source() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.starts_with("<?php"));
assert!(harness.source.contains("NYX_PAYLOAD"));
assert!(harness.source.contains("require_once"));
assert!(harness.source.contains("login($payload)"));
assert_eq!(harness.filename, "harness.php");
assert_eq!(harness.command, vec!["php", "harness.php"]);
}
#[test]
fn emit_param_index_0() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("login($payload)"));
}
#[test]
fn emit_param_index_2() {
let spec = make_spec(PayloadSlot::Param(2));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("login('', '', $payload)"));
}
#[test]
fn emit_env_var_slot() {
let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into()));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("putenv"));
assert!(harness.source.contains("\"DB_HOST\""));
}
#[test]
fn emit_http_body_is_unsupported() {
let spec = make_spec(PayloadSlot::HttpBody);
let err = emit(&spec).unwrap_err();
assert_eq!(err, UnsupportedReason::EntryKindUnsupported);
}
#[test]
fn emit_entry_subpath_is_entry_php() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert_eq!(harness.entry_subpath, Some("entry.php".to_owned()));
}
#[test]
fn harness_has_base64_decode() {
let spec = make_spec(PayloadSlot::Param(0));
let harness = emit(&spec).unwrap();
assert!(harness.source.contains("base64_decode"));
assert!(harness.source.contains("NYX_PAYLOAD_B64"));
}
}

View file

@ -134,8 +134,64 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
}
}
}
Lang::JavaScript | Lang::TypeScript => {
// npm install for dependency resolution (no deps in basic fixtures).
match build_sandbox::prepare_node(spec, &harness.workdir) {
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
_ => {}
}
}
Lang::Go => {
// Compile the harness binary with `go build -o nyx_harness .`.
match build_sandbox::prepare_go(spec, &harness.workdir) {
Ok(build_result) => {
let binary = build_result.venv_path.join("nyx_harness");
if binary.exists() {
harness.command = vec![binary.to_string_lossy().into_owned()];
} else {
let fallback = harness.workdir.join("nyx_harness");
if fallback.exists() {
harness.command = vec![fallback.to_string_lossy().into_owned()];
}
}
}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
Err(_) => {}
}
}
Lang::Java => {
// Compile NyxHarness.java + Entry.java with javac.
match build_sandbox::prepare_java(spec, &harness.workdir) {
Ok(_) => {
// Update classpath to absolute workdir path for Docker compatibility.
harness.command = vec![
"java".to_owned(),
"-cp".to_owned(),
harness.workdir.to_string_lossy().into_owned(),
"NyxHarness".to_owned(),
];
}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
Err(_) => {}
}
}
Lang::Php => {
// composer install if composer.json is present.
match build_sandbox::prepare_php(spec, &harness.workdir) {
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
_ => {}
}
}
_ => {
// No build step for other interpreted languages.
// No build step for other languages.
}
}

View file

@ -47,7 +47,7 @@ pub fn harness_is_interpreted(command: &[String]) -> bool {
.unwrap_or(cmd0);
matches!(
base,
"python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl"
"python3" | "python" | "python2" | "node" | "nodejs" | "ruby" | "php" | "perl" | "java"
)
}
@ -207,11 +207,25 @@ fn workdir_to_container_name(workdir: &Path) -> String {
/// Docker image tag for a Python toolchain ID (e.g. `python-3.11`).
fn python_image_for_toolchain(toolchain_id: &str) -> String {
// toolchain_id examples: "python-3", "python-3.11", "python-3.12"
let ver = toolchain_id.strip_prefix("python-").unwrap_or("3");
format!("python:{ver}-slim")
}
fn node_image_for_toolchain(toolchain_id: &str) -> String {
let ver = toolchain_id.strip_prefix("node-").unwrap_or("20");
format!("node:{ver}-slim")
}
fn java_image_for_toolchain(toolchain_id: &str) -> String {
let ver = toolchain_id.strip_prefix("java-").unwrap_or("21");
format!("eclipse-temurin:{ver}-jre-jammy")
}
fn php_image_for_toolchain(toolchain_id: &str) -> String {
let ver = toolchain_id.strip_prefix("php-").unwrap_or("8");
format!("php:{ver}-cli")
}
// ── Entry point ───────────────────────────────────────────────────────────────
/// Run a built harness once with a chosen payload.
@ -273,7 +287,7 @@ fn run_docker(
if !reused {
// Determine the Python image from the harness command (first element).
// Fall back to python:3-slim when the command is not recognised.
let image = detect_python_toolchain_from_harness(harness);
let image = detect_image_for_harness(harness);
start_container(&container_name, &harness.workdir, &image)?;
registry.insert(container_name.clone(), container_name.clone());
}
@ -374,6 +388,51 @@ fn start_container(name: &str, workdir: &Path, image: &str) -> Result<(), Sandbo
}
}
/// Build the inner-container command args for `docker exec`.
///
/// For 2-arg interpreted commands (`python3 harness.py`, `node harness.js`,
/// `php harness.php`) the file arg is prefixed with `/workdir/`.
/// For Java (`java -cp /host/abs/path NyxHarness`) the classpath argument is
/// replaced with `/workdir` (the container-side mount path, not the host path
/// that runner.rs wrote after `javac`).
fn build_container_exec_args(command: &[String]) -> Vec<String> {
let mut args = Vec::new();
let cmd0 = match command.first() {
Some(c) => c.as_str(),
None => return args,
};
let base = std::path::Path::new(cmd0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(cmd0);
if base == "java" {
args.push("java".to_owned());
let mut i = 1;
while i < command.len() {
if command[i] == "-cp" || command[i] == "-classpath" {
args.push(command[i].clone());
i += 1;
args.push("/workdir".to_owned());
i += 1;
} else {
args.push(command[i].clone());
i += 1;
}
}
} else {
args.push(cmd0.to_owned());
if let Some(harness_file) = command.get(1) {
if harness_file.starts_with('/') {
args.push(harness_file.clone());
} else {
args.push(format!("/workdir/{harness_file}"));
}
}
}
args
}
/// Execute the harness inside an already-running container.
fn exec_in_container(
container_name: &str,
@ -405,15 +464,10 @@ fn exec_in_container(
}
cmd_args.push(container_name.into());
// Build the exec command inside the container (always interpreted at this point).
let exec_cmd = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
let harness_file = harness
.command
.get(1)
.map(|s| s.as_str())
.unwrap_or("harness.py");
cmd_args.push(exec_cmd.into());
cmd_args.push(format!("/workdir/{harness_file}"));
// Build the exec command inside the container.
for arg in build_container_exec_args(&harness.command) {
cmd_args.push(arg);
}
let mut cmd = Command::new(docker_bin());
cmd.args(&cmd_args);
@ -495,20 +549,33 @@ fn exec_in_container(
})
}
/// Detect the Python image to use based on the harness command.
/// Detect the Docker image for the harness based on the interpreter command.
///
/// The first element of `harness.command` is typically `python3` or a venv
/// path like `/path/to/venv/bin/python3`. Fall back to `python:3-slim`.
fn detect_python_toolchain_from_harness(harness: &BuiltHarness) -> String {
// The harness workdir encodes the spec_hash but not the toolchain.
// Use the default image for Python; callers that know the toolchain_id
// should pass it through BuiltHarness.env (NYX_TOOLCHAIN_ID) when needed.
/// Dispatches by the basename of `command[0]` (e.g. `python3`, `node`, `java`,
/// `php`). Falls back to `python:3-slim` for unrecognised interpreters.
/// `NYX_TOOLCHAIN_ID` env var overrides the version portion of the image tag.
fn detect_image_for_harness(harness: &BuiltHarness) -> String {
let cmd0 = harness.command.first().map(|s| s.as_str()).unwrap_or("python3");
let base = std::path::Path::new(cmd0)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(cmd0);
if let Ok(tid) = std::env::var("NYX_TOOLCHAIN_ID") {
return python_image_for_toolchain(&tid);
return match base {
"node" | "nodejs" => node_image_for_toolchain(&tid),
"java" => java_image_for_toolchain(&tid),
"php" => php_image_for_toolchain(&tid),
_ => python_image_for_toolchain(&tid),
};
}
match base {
"node" | "nodejs" => "node:20-slim".to_owned(),
"java" => "eclipse-temurin:21-jre-jammy".to_owned(),
"php" => "php:8-cli".to_owned(),
_ => "python:3-slim".to_owned(),
}
// Default to python:3-slim which is always available in CI.
let _ = harness;
"python:3-slim".to_owned()
}
// ── Process backend ───────────────────────────────────────────────────────────
@ -804,6 +871,82 @@ mod tests {
assert_eq!(python_image_for_toolchain("python-3.12"), "python:3.12-slim");
}
#[test]
fn node_image_for_known_toolchains() {
assert_eq!(node_image_for_toolchain("node-20"), "node:20-slim");
assert_eq!(node_image_for_toolchain("node-18"), "node:18-slim");
assert_eq!(node_image_for_toolchain("node-lts"), "node:lts-slim");
}
#[test]
fn java_image_for_known_toolchains() {
assert_eq!(java_image_for_toolchain("java-21"), "eclipse-temurin:21-jre-jammy");
assert_eq!(java_image_for_toolchain("java-17"), "eclipse-temurin:17-jre-jammy");
}
#[test]
fn php_image_for_known_toolchains() {
assert_eq!(php_image_for_toolchain("php-8"), "php:8-cli");
assert_eq!(php_image_for_toolchain("php-8.2"), "php:8.2-cli");
}
#[test]
fn harness_is_interpreted_java() {
let cmd = vec!["java".to_owned(), "-cp".to_owned(), ".".to_owned(), "NyxHarness".to_owned()];
assert!(harness_is_interpreted(&cmd));
}
#[test]
fn harness_is_interpreted_node() {
assert!(harness_is_interpreted(&["node".to_owned(), "harness.js".to_owned()]));
}
#[test]
fn build_container_exec_args_python() {
let cmd = vec!["python3".to_owned(), "harness.py".to_owned()];
assert_eq!(
build_container_exec_args(&cmd),
vec!["python3", "/workdir/harness.py"]
);
}
#[test]
fn build_container_exec_args_node() {
let cmd = vec!["node".to_owned(), "harness.js".to_owned()];
assert_eq!(
build_container_exec_args(&cmd),
vec!["node", "/workdir/harness.js"]
);
}
#[test]
fn build_container_exec_args_php() {
let cmd = vec!["php".to_owned(), "harness.php".to_owned()];
assert_eq!(
build_container_exec_args(&cmd),
vec!["php", "/workdir/harness.php"]
);
}
#[test]
fn build_container_exec_args_java() {
let cmd = vec![
"java".to_owned(),
"-cp".to_owned(),
"/tmp/nyx-harness/abc123".to_owned(),
"NyxHarness".to_owned(),
];
assert_eq!(
build_container_exec_args(&cmd),
vec!["java", "-cp", "/workdir", "NyxHarness"]
);
}
#[test]
fn build_container_exec_args_empty() {
assert!(build_container_exec_args(&[]).is_empty());
}
/// Verify that a second sandbox::run call for the same workdir does NOT
/// start a new container when one is already registered.
///

View file

@ -38,6 +38,16 @@ pub enum PinOrigin {
RustToolchainFile,
/// `Cargo.toml` `rust-version` field.
CargoToml,
/// `package.json` `engines.node` field.
PackageJson,
/// `go.mod` `go` directive.
GoMod,
/// `pom.xml` `<java.version>` / `<maven.compiler.source>`.
PomXml,
/// `build.gradle` `sourceCompatibility` / `java.toolchain.languageVersion`.
BuildGradle,
/// `composer.json` `require.php`.
ComposerJson,
/// No pin found; used the system default.
SystemDefault,
}
@ -308,6 +318,371 @@ fn map_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
}
}
// ── Node.js toolchain resolver ────────────────────────────────────────────────
/// Resolve the Node.js toolchain for `project_root`.
///
/// Reads pin files in priority order:
/// `.nvmrc` > `package.json` `engines.node` > `.node-version` > default.
pub fn resolve_node(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_nvmrc(project_root) {
return r;
}
if let Some(r) = try_package_json_engines(project_root) {
return r;
}
if let Some(r) = try_node_version_file(project_root) {
return r;
}
default_node()
}
fn try_nvmrc(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join(".nvmrc")).ok()?;
let version = content.trim().trim_start_matches('v').to_owned();
if version.is_empty() {
return None;
}
Some(map_node_version(&version, PinOrigin::PackageJson))
}
fn try_package_json_engines(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("package.json")).ok()?;
// Look for "node": ">=18" or "node": "20.x" under "engines".
let mut in_engines = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("\"engines\"") {
in_engines = true;
}
if in_engines && trimmed.contains("\"node\"") {
// Extract version from: "node": ">=18" or "node": "20"
if let Some(ver) = extract_version_from_json_value(trimmed) {
return Some(map_node_version(&ver, PinOrigin::PackageJson));
}
}
if in_engines && trimmed.starts_with('}') {
in_engines = false;
}
}
None
}
fn try_node_version_file(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join(".node-version")).ok()?;
let version = content.trim().trim_start_matches('v').to_owned();
if version.is_empty() {
return None;
}
Some(map_node_version(&version, PinOrigin::PackageJson))
}
fn default_node() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "node-20".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "20".to_owned(),
}
}
fn map_node_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
// Strip leading >= <= ~ ^ comparators.
let ver = version.trim_start_matches(|c: char| !c.is_ascii_digit());
let parts: Vec<&str> = ver.splitn(3, '.').collect();
let major = parts.first().copied().unwrap_or("20");
// Node.js LTS catalog: 18, 20, 22.
let (toolchain_id, drift) = match major.parse::<u32>() {
Ok(n) if n < 18 => (format!("node-{n}"), true),
Ok(18) => ("node-18".to_owned(), false),
Ok(20) => ("node-20".to_owned(), false),
Ok(22) => ("node-22".to_owned(), false),
Ok(n) => (format!("node-{n}"), true),
_ => ("node-20".to_owned(), true),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
/// Extract a version string from a JSON value like `">=18"` or `"20.x"`.
fn extract_version_from_json_value(line: &str) -> Option<String> {
// Find the second quoted value after the colon.
let after_colon = line.splitn(2, ':').nth(1)?;
let raw = after_colon.trim().trim_matches('"').trim_matches('\'');
let ver = raw.trim_start_matches(|c: char| !c.is_ascii_digit());
// Strip trailing .x or .* wildcards.
let ver = if let Some(pos) = ver.find(".x") {
&ver[..pos]
} else if let Some(pos) = ver.find(".*") {
&ver[..pos]
} else {
ver
};
if ver.is_empty() {
return None;
}
Some(ver.to_owned())
}
// ── Go toolchain resolver ─────────────────────────────────────────────────────
/// Resolve the Go toolchain for `project_root`.
///
/// Reads pin files in priority order: `go.mod` `go` directive > default.
pub fn resolve_go(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_go_mod(project_root) {
return r;
}
default_go()
}
fn try_go_mod(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("go.mod")).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if let Some(rest) = trimmed.strip_prefix("go ") {
let version = rest.trim().to_owned();
if !version.is_empty() {
return Some(map_go_version(&version, PinOrigin::GoMod));
}
}
}
None
}
fn default_go() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "go-stable".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "stable".to_owned(),
}
}
fn map_go_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
let parts: Vec<&str> = version.splitn(3, '.').collect();
let major = parts.first().copied().unwrap_or("1");
let minor = parts.get(1).copied();
// Go 1.21+ is the modern catalog.
let (toolchain_id, drift) = match (major, minor) {
("1", Some("21")) => ("go-1.21".to_owned(), false),
("1", Some("22")) => ("go-1.22".to_owned(), false),
("1", Some("23")) => ("go-1.23".to_owned(), false),
("1", Some(m)) if m.parse::<u32>().map_or(false, |v| v >= 24) => {
(format!("go-1.{m}"), true)
}
("1", Some(m)) if m.parse::<u32>().map_or(false, |v| v < 21) => {
(format!("go-1.{m}"), true)
}
_ => ("go-stable".to_owned(), false),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
// ── Java toolchain resolver ───────────────────────────────────────────────────
/// Resolve the Java toolchain for `project_root`.
///
/// Reads pin files in priority order:
/// `pom.xml` `<java.version>` / `<maven.compiler.source>` >
/// `build.gradle` `sourceCompatibility` > default.
pub fn resolve_java(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_pom_xml(project_root) {
return r;
}
if let Some(r) = try_build_gradle(project_root) {
return r;
}
default_java()
}
fn try_pom_xml(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("pom.xml")).ok()?;
// Look for <java.version>21</java.version> or <maven.compiler.source>21</...>
for line in content.lines() {
let trimmed = line.trim();
for tag in &["<java.version>", "<maven.compiler.source>", "<maven.compiler.release>"] {
if trimmed.starts_with(tag) {
if let Some(inner) = trimmed.strip_prefix(tag) {
let version = inner.split('<').next().unwrap_or("").trim();
if !version.is_empty() {
return Some(map_java_version(version, PinOrigin::PomXml));
}
}
}
}
}
None
}
fn try_build_gradle(root: &Path) -> Option<ToolchainResolution> {
for fname in &["build.gradle", "build.gradle.kts"] {
let Ok(content) = std::fs::read_to_string(root.join(fname)) else {
continue;
};
for line in content.lines() {
let trimmed = line.trim();
// Groovy: sourceCompatibility = '21' or JavaVersion.VERSION_21
// Kotlin: sourceCompatibility = JavaVersion.VERSION_21
if trimmed.starts_with("sourceCompatibility") || trimmed.starts_with("languageVersion") {
if let Some(ver) = extract_java_version_from_gradle_line(trimmed) {
return Some(map_java_version(&ver, PinOrigin::BuildGradle));
}
}
}
}
None
}
fn extract_java_version_from_gradle_line(line: &str) -> Option<String> {
// Handle: sourceCompatibility = '21' or sourceCompatibility = 21
// and: languageVersion.set(JavaLanguageVersion.of(21))
let after_eq = line.splitn(2, '=').nth(1).unwrap_or(line);
// Try to find a number in the value.
let digits: String = after_eq.chars()
.skip_while(|c| !c.is_ascii_digit())
.take_while(|c| c.is_ascii_digit())
.collect();
if digits.is_empty() {
// Try "VERSION_21" pattern.
if let Some(pos) = after_eq.find("VERSION_") {
let rest = &after_eq[pos + 8..];
let digits: String = rest.chars()
.take_while(|c| c.is_ascii_digit())
.collect();
if !digits.is_empty() {
return Some(digits);
}
}
return None;
}
Some(digits)
}
fn default_java() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "java-21".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "21".to_owned(),
}
}
fn map_java_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
// Java version: 8, 11, 17, 21, 22 are common LTS/current.
let major = version.split('.').next().unwrap_or(version);
let (toolchain_id, drift) = match major.parse::<u32>() {
Ok(8) => ("java-8".to_owned(), false),
Ok(11) => ("java-11".to_owned(), false),
Ok(17) => ("java-17".to_owned(), false),
Ok(21) => ("java-21".to_owned(), false),
Ok(n) => (format!("java-{n}"), true),
_ => ("java-21".to_owned(), true),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
// ── PHP toolchain resolver ────────────────────────────────────────────────────
/// Resolve the PHP toolchain for `project_root`.
///
/// Reads pin files in priority order:
/// `composer.json` `require.php` > `.php-version` > default.
pub fn resolve_php(project_root: &Path) -> ToolchainResolution {
if let Some(r) = try_composer_json(project_root) {
return r;
}
if let Some(r) = try_php_version_file(project_root) {
return r;
}
default_php()
}
fn try_composer_json(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join("composer.json")).ok()?;
// Look for "php": ">=8.1" under "require".
let mut in_require = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("\"require\"") {
in_require = true;
}
if in_require && trimmed.contains("\"php\"") {
if let Some(ver) = extract_version_from_json_value(trimmed) {
return Some(map_php_version(&ver, PinOrigin::ComposerJson));
}
}
// Stop at closing brace of require block.
if in_require && trimmed == "}," || (in_require && trimmed == "}") {
in_require = false;
}
}
None
}
fn try_php_version_file(root: &Path) -> Option<ToolchainResolution> {
let content = std::fs::read_to_string(root.join(".php-version")).ok()?;
let version = content.trim().to_owned();
if version.is_empty() {
return None;
}
Some(map_php_version(&version, PinOrigin::ComposerJson))
}
fn default_php() -> ToolchainResolution {
ToolchainResolution {
toolchain_id: "php-8".to_owned(),
pin_origin: PinOrigin::SystemDefault,
toolchain_drift: false,
version_string: "8".to_owned(),
}
}
fn map_php_version(version: &str, origin: PinOrigin) -> ToolchainResolution {
let ver = version.trim_start_matches(|c: char| !c.is_ascii_digit());
let parts: Vec<&str> = ver.splitn(3, '.').collect();
let major = parts.first().copied().unwrap_or("8");
let minor = parts.get(1).copied();
let (toolchain_id, drift) = match (major.parse::<u32>(), minor) {
(Ok(8), Some("0")) => ("php-8.0".to_owned(), false),
(Ok(8), Some("1")) => ("php-8.1".to_owned(), false),
(Ok(8), Some("2")) => ("php-8.2".to_owned(), false),
(Ok(8), Some("3")) => ("php-8.3".to_owned(), false),
(Ok(8), None) => ("php-8".to_owned(), false),
(Ok(7), _) => ("php-7".to_owned(), true),
(Ok(n), _) => (format!("php-{n}"), true),
_ => ("php-8".to_owned(), true),
};
ToolchainResolution {
toolchain_id,
pin_origin: origin,
toolchain_drift: drift,
version_string: version.to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -401,4 +776,112 @@ mod tests {
assert_eq!(r.toolchain_id, "rust-stable");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── Node.js resolver tests ────────────────────────────────────────────────
#[test]
fn node_nvmrc_exact() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join(".nvmrc"), "v20.5.0\n").unwrap();
let r = resolve_node(dir.path());
assert_eq!(r.toolchain_id, "node-20");
assert!(!r.toolchain_drift);
assert_eq!(r.pin_origin, PinOrigin::PackageJson);
}
#[test]
fn node_package_json_engines() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"engines": {"node": ">=18.0.0"}}"#,
).unwrap();
let r = resolve_node(dir.path());
assert_eq!(r.toolchain_id, "node-18");
}
#[test]
fn node_default_is_20() {
let dir = TempDir::new().unwrap();
let r = resolve_node(dir.path());
assert_eq!(r.toolchain_id, "node-20");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── Go resolver tests ─────────────────────────────────────────────────────
#[test]
fn go_mod_version() {
let dir = TempDir::new().unwrap();
fs::write(dir.path().join("go.mod"), "module example.com/app\n\ngo 1.22\n").unwrap();
let r = resolve_go(dir.path());
assert_eq!(r.toolchain_id, "go-1.22");
assert!(!r.toolchain_drift);
assert_eq!(r.pin_origin, PinOrigin::GoMod);
}
#[test]
fn go_default_is_stable() {
let dir = TempDir::new().unwrap();
let r = resolve_go(dir.path());
assert_eq!(r.toolchain_id, "go-stable");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── Java resolver tests ───────────────────────────────────────────────────
#[test]
fn java_pom_xml_version() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("pom.xml"),
"<project>\n <properties>\n <java.version>21</java.version>\n </properties>\n</project>",
).unwrap();
let r = resolve_java(dir.path());
assert_eq!(r.toolchain_id, "java-21");
assert!(!r.toolchain_drift);
assert_eq!(r.pin_origin, PinOrigin::PomXml);
}
#[test]
fn java_build_gradle_source_compat() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("build.gradle"),
"sourceCompatibility = '17'\ntargetCompatibility = '17'\n",
).unwrap();
let r = resolve_java(dir.path());
assert_eq!(r.toolchain_id, "java-17");
assert_eq!(r.pin_origin, PinOrigin::BuildGradle);
}
#[test]
fn java_default_is_21() {
let dir = TempDir::new().unwrap();
let r = resolve_java(dir.path());
assert_eq!(r.toolchain_id, "java-21");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
// ── PHP resolver tests ────────────────────────────────────────────────────
#[test]
fn php_composer_json_version() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("composer.json"),
r#"{"require": {"php": ">=8.1"}}"#,
).unwrap();
let r = resolve_php(dir.path());
assert_eq!(r.toolchain_id, "php-8.1");
assert_eq!(r.pin_origin, PinOrigin::ComposerJson);
}
#[test]
fn php_default_is_8() {
let dir = TempDir::new().unwrap();
let r = resolve_php(dir.path());
assert_eq!(r.toolchain_id, "php-8");
assert_eq!(r.pin_origin, PinOrigin::SystemDefault);
}
}

View file

@ -98,6 +98,10 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
use crate::symbol::Lang;
let toolchain_res = match spec.lang {
Lang::Rust => toolchain::resolve_rust(Path::new(".")),
Lang::JavaScript | Lang::TypeScript => toolchain::resolve_node(Path::new(".")),
Lang::Go => toolchain::resolve_go(Path::new(".")),
Lang::Java => toolchain::resolve_java(Path::new(".")),
Lang::Php => toolchain::resolve_php(Path::new(".")),
_ => toolchain::resolve_python(Path::new(".")),
};
let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" };