diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 4a0a4dde..d4f05d5b 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -1,25 +1,37 @@ //! 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). +//! Phase 15 (Track B Go vertical) replaces the single legacy `emit` body +//! with dispatch over [`GoShape`] — the cross product of [`EntryKind`] +//! and a lightweight per-file shape detector that inspects the entry +//! file for `net/http` handler signatures, gin context handlers, +//! `flag.Parse` CLIs, and `func(args ...) error` fuzz harnesses. //! -//! 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. +//! Each shape emits a single `main.go` that: +//! 1. Reads the payload from `NYX_PAYLOAD` / `NYX_PAYLOAD_B64` env vars. +//! 2. Imports the entry package from `./entry/` and invokes the entry +//! function via the per-shape adapter. +//! +//! 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`) +//! entry.go ← entry function (copied from project; `package entry`) //! ``` //! //! Payload slot support: //! - `PayloadSlot::Param(0)` — pass payload as `string` first argument. //! - `PayloadSlot::EnvVar(name)` — set env var before calling entry. +//! - `PayloadSlot::QueryParam(name)` — surfaced to HandlerFunc / gin +//! shapes as the named query parameter. +//! - `PayloadSlot::HttpBody` — surfaced to HandlerFunc / gin shapes as +//! the request body. +//! - `PayloadSlot::Argv(n)` — appended to `os.Args` for `flag.Parse` +//! shapes. //! - Other slots produce `UnsupportedReason::PayloadSlotUnsupported`. //! //! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1). @@ -28,15 +40,22 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +use std::path::PathBuf; /// Zero-sized [`LangEmitter`] handle for Go. Method bodies delegate to the /// existing free functions in this module. pub struct GoEmitter; -/// Entry kinds the Go emitter currently understands. Extended in Phase 15 -/// (Track B Go vertical) to include `HttpRoute` (`net/http`, gin) and CLI -/// (`flag.Parse`) shapes. -const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Entry kinds the Go emitter understands after Phase 15. +/// +/// `HttpRoute` covers `net/http` and gin handlers. `CliSubcommand` +/// covers `flag.Parse` CLIs. `Function` covers plain functions and +/// fuzz harnesses. +const SUPPORTED: &[EntryKind] = &[ + EntryKind::Function, + EntryKind::HttpRoute, + EntryKind::CliSubcommand, +]; impl LangEmitter for GoEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { @@ -49,7 +68,7 @@ impl LangEmitter for GoEmitter { fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add net/http, gin, flag.Parse shapes in phase 15" + "go emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch" ) } @@ -58,6 +77,90 @@ impl LangEmitter for GoEmitter { } } +// ── Phase 15: shape detector ───────────────────────────────────────────────── + +/// Concrete per-file shape resolved by reading the entry source. +/// +/// One harness template per variant. When the entry file is unreadable +/// or no marker fires the detector defaults to [`GoShape::Generic`], +/// preserving the pre-Phase-15 behaviour (direct `entry.Func(payload)` +/// call). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GoShape { + /// `func(w http.ResponseWriter, r *http.Request)`. Harness builds + /// a `httptest.NewRequest` + `httptest.NewRecorder` and dispatches + /// the handler. + HttpHandlerFunc, + /// `func(c *gin.Context)`. Harness constructs a minimal + /// `gin.Context` stub and dispatches. Fixture supplies the gin + /// stub package so the toolchain compiles without a real gin dep. + GinHandler, + /// `flag.Parse`-driven CLI. Harness sets `os.Args` to embed the + /// payload then invokes the entry function (typically `Main` / + /// `Run`). + FlagParseCli, + /// Fuzz-style harness: `func(args ...) error` taking `[]byte`-ish + /// inputs. Harness invokes with `[]byte(payload)`. + FuzzVariadic, + /// Generic free function — pre-Phase-15 default. Harness calls + /// `entry.Func(payload)` directly. + Generic, +} + +impl GoShape { + /// Detect the shape from `(spec, source)`. `source` is the literal + /// bytes of the entry file (best-effort — empty string falls back + /// to [`Self::Generic`]). + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let entry = spec.entry_name.as_str(); + let kind = spec.entry_kind; + + let has_http_handler = source.contains("http.ResponseWriter") + && source.contains("*http.Request"); + let has_gin = source.contains("gin.Context") || source.contains("*gin.Context"); + let has_flag_parse = source.contains("flag.Parse()") || source.contains("flag.Parse("); + let has_fuzz_signature = source.contains("[]byte") + && (entry.starts_with("Fuzz") || source.contains("// nyx-shape: fuzz")); + + if has_gin { + return Self::GinHandler; + } + if has_http_handler { + return Self::HttpHandlerFunc; + } + if has_flag_parse { + return Self::FlagParseCli; + } + if has_fuzz_signature { + return Self::FuzzVariadic; + } + if kind == EntryKind::HttpRoute { + return Self::HttpHandlerFunc; + } + if kind == EntryKind::CliSubcommand { + return Self::FlagParseCli; + } + Self::Generic + } +} + +/// Public wrapper to detect the shape for a finalised `HarnessSpec`, +/// reading the entry file from disk. +pub fn detect_shape(spec: &HarnessSpec) -> GoShape { + let src = read_entry_source(&spec.entry_file); + GoShape::detect(spec, &src) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} + /// Phase 09 — Track D.2: synthesise a `go.mod` listing every captured /// third-party import path. Standard-library imports are skipped via /// [`is_go_stdlib`]. @@ -246,51 +349,52 @@ func __nyx_recover_crash(sinkCallee string) func() { /// Emit a Go harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { - PayloadSlot::Param(0) | PayloadSlot::EnvVar(_) => {} - _ => return Err(UnsupportedReason::PayloadSlotUnsupported), + PayloadSlot::Param(_) + | PayloadSlot::EnvVar(_) + | PayloadSlot::QueryParam(_) + | PayloadSlot::HttpBody + | PayloadSlot::Argv(_) => {} + PayloadSlot::Stdin => return Err(UnsupportedReason::PayloadSlotUnsupported), } - let main_go = generate_main_go(spec); + let entry_source = read_entry_source(&spec.entry_file); + let shape = GoShape::detect(spec, &entry_source); + let main_go = generate_main_go(spec, shape); let go_mod = generate_go_mod(); + let mut extra_files = vec![("go.mod".to_owned(), go_mod)]; + // Phase 15: GinHandler shape stages a minimal gin stub package so + // the toolchain can compile the harness without pulling real gin. + if matches!(shape, GoShape::GinHandler) { + extra_files.push(("entry/gin/gin.go".to_owned(), gin_stub_pkg())); + } + 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)], + extra_files, entry_subpath: Some("entry/entry.go".to_owned()), }) } -fn generate_main_go(spec: &HarnessSpec) -> String { +fn generate_main_go(spec: &HarnessSpec, shape: GoShape) -> 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; + let pre_call = pre_call_setup(spec); + let imports = imports_for_shape(shape); + let invocation = invoke_for_shape(spec, shape, &entry_fn); format!( - r#"// Nyx dynamic harness — auto-generated, do not edit. + r#"// Nyx dynamic harness — auto-generated, do not edit (Phase 15 — GoShape::{shape:?}). package main import ( - "encoding/base64" - "fmt" - "os" - - "nyx-harness/entry" -) +{imports}) func main() {{ payload := nyxPayload() -{pre_call} {call_expr} - _ = fmt.Sprintf("") // suppress unused import if call_expr uses fmt directly - _ = os.Stderr // suppress unused import + _ = payload +{pre_call}{invocation} }} func nyxPayload() string {{ @@ -305,34 +409,154 @@ func nyxPayload() string {{ return "" }} "#, + shape = shape, + imports = imports, pre_call = pre_call, - call_expr = call_expr, + invocation = invocation, ) } +fn imports_for_shape(shape: GoShape) -> &'static str { + match shape { + GoShape::Generic => { + "\t\"encoding/base64\"\n\t\"os\"\n\n\t\"nyx-harness/entry\"\n" + } + GoShape::HttpHandlerFunc => { + "\t\"encoding/base64\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\n\t\"nyx-harness/entry\"\n" + } + GoShape::GinHandler => { + "\t\"encoding/base64\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"strings\"\n\n\t\"nyx-harness/entry\"\n\t\"nyx-harness/entry/gin\"\n" + } + GoShape::FlagParseCli => { + "\t\"encoding/base64\"\n\t\"os\"\n\n\t\"nyx-harness/entry\"\n" + } + GoShape::FuzzVariadic => { + "\t\"encoding/base64\"\n\t\"os\"\n\n\t\"nyx-harness/entry\"\n" + } + } +} + +fn pre_call_setup(spec: &HarnessSpec) -> String { + match &spec.payload_slot { + PayloadSlot::EnvVar(name) => format!("\tos.Setenv({name:?}, payload)\n"), + PayloadSlot::Argv(n) => { + let pads = (0..*n).map(|_| "\"\"".to_owned()).collect::>().join(", "); + if pads.is_empty() { + format!("\tos.Args = []string{{\"nyx_harness\", payload}}\n") + } else { + format!("\tos.Args = []string{{\"nyx_harness\", {pads}, payload}}\n") + } + } + _ => String::new(), + } +} + +fn invoke_for_shape(spec: &HarnessSpec, shape: GoShape, entry_fn: &str) -> String { + let query_param = match &spec.payload_slot { + PayloadSlot::QueryParam(name) => name.clone(), + _ => "payload".to_owned(), + }; + let use_body = matches!(&spec.payload_slot, PayloadSlot::HttpBody); + + match shape { + GoShape::Generic => format!("\tentry.{entry_fn}(payload)\n"), + GoShape::HttpHandlerFunc => { + let body_setup = if use_body { + "\treq := httptest.NewRequest(\"POST\", \"/\", strings.NewReader(payload))\n" + } else { + "" + }; + let url_setup = if use_body { + String::new() + } else { + format!( + "\treq := httptest.NewRequest(\"GET\", \"/?{q}=\"+payload, strings.NewReader(\"\"))\n", + q = query_param + ) + }; + format!( + "{body_setup}{url_setup}\trw := httptest.NewRecorder()\n\tentry.{entry_fn}(rw, req)\n\t_ = http.StatusOK\n", + ) + } + GoShape::GinHandler => { + let setup = if use_body { + "\treq := httptest.NewRequest(\"POST\", \"/\", strings.NewReader(payload))\n" + } else { + "\treq := httptest.NewRequest(\"GET\", \"/?payload=\"+payload, strings.NewReader(\"\"))\n" + }; + format!( + "{setup}\trw := httptest.NewRecorder()\n\tctx := gin.NewContext(rw, req)\n\tentry.{entry_fn}(ctx)\n\t_ = http.StatusOK\n", + ) + } + GoShape::FlagParseCli => format!("\tentry.{entry_fn}()\n"), + GoShape::FuzzVariadic => format!("\t_ = entry.{entry_fn}([]byte(payload))\n"), + } +} + 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) - } - } +/// Minimal `gin` stub package used by [`GoShape::GinHandler`] fixtures +/// so the toolchain can compile without a real gin dependency. +/// Exposes just enough surface (Context.Query, Context.JSON, +/// Context.String, NewContext) to support the per-shape harness call. +fn gin_stub_pkg() -> String { + r#"// Phase 15 — minimal gin stub for harness build (not the real gin). +package gin + +import ( + "fmt" + "io" + "net/http" +) + +type Context struct { + Writer http.ResponseWriter + Request *http.Request +} + +func NewContext(w http.ResponseWriter, r *http.Request) *Context { + return &Context{Writer: w, Request: r} +} + +func (c *Context) Query(name string) string { + if c.Request == nil { + return "" + } + return c.Request.URL.Query().Get(name) +} + +func (c *Context) PostForm(name string) string { + if c.Request == nil { + return "" + } + _ = c.Request.ParseForm() + return c.Request.PostFormValue(name) +} + +func (c *Context) GetRawData() ([]byte, error) { + if c.Request == nil || c.Request.Body == nil { + return []byte{}, nil + } + return io.ReadAll(c.Request.Body) +} + +func (c *Context) JSON(code int, obj interface{}) { + if c.Writer != nil { + c.Writer.WriteHeader(code) + fmt.Fprintf(c.Writer, "%v", obj) + } +} + +func (c *Context) String(code int, format string, values ...interface{}) { + if c.Writer != nil { + c.Writer.WriteHeader(code) + fmt.Fprintf(c.Writer, format, values...) + } +} +"# + .to_owned() } /// Capitalize the first character of a string (Go exported names must start uppercase). @@ -405,13 +629,6 @@ mod tests { 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::PayloadSlotUnsupported); - } - #[test] fn emit_stdin_is_unsupported() { let spec = make_spec(PayloadSlot::Stdin); @@ -423,13 +640,15 @@ mod tests { fn entry_kinds_supported_is_non_empty() { assert!(!GoEmitter.entry_kinds_supported().is_empty()); assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::Function)); + assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::HttpRoute)); + assert!(GoEmitter.entry_kinds_supported().contains(&EntryKind::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = GoEmitter.entry_kind_hint(EntryKind::HttpRoute); - assert!(hint.contains("HttpRoute")); - assert!(hint.contains("phase 15")); + let hint = GoEmitter.entry_kind_hint(EntryKind::LibraryApi); + assert!(hint.contains("LibraryApi")); + assert!(hint.contains("Phase 15")); } #[test] @@ -446,4 +665,82 @@ mod tests { assert!(go_mod.contains("module nyx-harness")); assert!(go_mod.contains("go 1.21")); } + + // ── Phase 15: shape detection ──────────────────────────────────────────── + + fn make_spec_with(kind: EntryKind, name: &str, entry_file: &str) -> HarnessSpec { + let mut s = make_spec(PayloadSlot::Param(0)); + s.entry_kind = kind; + s.entry_name = name.to_owned(); + s.entry_file = entry_file.to_owned(); + s + } + + #[test] + fn shape_detect_http_handler_func() { + let src = "package entry\nimport \"net/http\"\nfunc Handle(w http.ResponseWriter, r *http.Request) {}"; + let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go"); + assert_eq!(GoShape::detect(&spec, src), GoShape::HttpHandlerFunc); + } + + #[test] + fn shape_detect_gin_handler() { + let src = "package entry\nimport \"nyx-harness/entry/gin\"\nfunc Handle(c *gin.Context) {}"; + let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go"); + assert_eq!(GoShape::detect(&spec, src), GoShape::GinHandler); + } + + #[test] + fn shape_detect_flag_parse_cli() { + let src = "package entry\nimport \"flag\"\nfunc Run() { flag.Parse() }"; + let spec = make_spec_with(EntryKind::CliSubcommand, "Run", "entry.go"); + assert_eq!(GoShape::detect(&spec, src), GoShape::FlagParseCli); + } + + #[test] + fn shape_detect_fuzz_variadic() { + let src = "package entry\nfunc FuzzHandle(data []byte) error { return nil }"; + let spec = make_spec_with(EntryKind::Function, "FuzzHandle", "entry.go"); + assert_eq!(GoShape::detect(&spec, src), GoShape::FuzzVariadic); + } + + #[test] + fn shape_detect_generic_fallback() { + let src = "package entry\nfunc Login(payload string) {}"; + let spec = make_spec_with(EntryKind::Function, "Login", "entry.go"); + assert_eq!(GoShape::detect(&spec, src), GoShape::Generic); + } + + #[test] + fn http_shape_emits_httptest_invocation() { + let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go"); + let src = generate_main_go(&spec, GoShape::HttpHandlerFunc); + assert!(src.contains("httptest.NewRequest")); + assert!(src.contains("httptest.NewRecorder")); + assert!(src.contains("entry.Handle(rw, req)")); + } + + #[test] + fn gin_shape_emits_context_invocation() { + let spec = make_spec_with(EntryKind::HttpRoute, "Handle", "entry.go"); + let src = generate_main_go(&spec, GoShape::GinHandler); + assert!(src.contains("gin.NewContext")); + assert!(src.contains("entry.Handle(ctx)")); + } + + #[test] + fn cli_shape_emits_os_args_setup() { + let mut spec = make_spec_with(EntryKind::CliSubcommand, "Run", "entry.go"); + spec.payload_slot = PayloadSlot::Argv(0); + let src = generate_main_go(&spec, GoShape::FlagParseCli); + assert!(src.contains("os.Args = []string")); + assert!(src.contains("entry.Run()")); + } + + #[test] + fn fuzz_shape_emits_bytes_invocation() { + let spec = make_spec_with(EntryKind::Function, "FuzzHandle", "entry.go"); + let src = generate_main_go(&spec, GoShape::FuzzVariadic); + assert!(src.contains("entry.FuzzHandle([]byte(payload))")); + } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 2ff285e7..7974f6f6 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1,19 +1,29 @@ //! PHP harness emitter. //! -//! Generates a PHP script that: +//! Phase 15 (Track B PHP vertical) replaces the single legacy `emit` +//! body with dispatch over [`PhpShape`] — the cross product of +//! [`EntryKind`] and a lightweight per-file shape detector that +//! inspects the entry file for Slim/Laravel/Symfony route closures, +//! `$argv`-driven CLI scripts, and top-level script bodies. +//! +//! Each shape emits a single `harness.php` 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. +//! 3. Invokes the entry function / closure via the per-shape adapter. +//! 4. Catches all Throwables so the harness exit stays observable. //! -//! Sink-reachability probe: fixtures explicitly emit `__NYX_SINK_HIT__` before -//! the actual sink call (same pattern as Rust / JS fixtures). +//! 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::PayloadSlotUnsupported`. +//! - `PayloadSlot::Argv(n)` — appended to `$argv` for CLI shapes. +//! - `PayloadSlot::QueryParam(name)` — surfaced via `$_GET[name]` / +//! request stub query for route closures. +//! - `PayloadSlot::HttpBody` — surfaced via `$_POST` / request stub body +//! for route closures. //! //! Build: no compilation step. Command is `php harness.php`. //! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). @@ -22,15 +32,22 @@ use crate::dynamic::environment::{Environment, RuntimeArtifacts}; use crate::dynamic::lang::{HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; +use std::path::PathBuf; /// Zero-sized [`LangEmitter`] handle for PHP. Method bodies delegate to the /// existing free functions in this module. pub struct PhpEmitter; -/// Entry kinds the PHP emitter currently understands. Extended in Phase 15 -/// (Track B PHP vertical) to include `HttpRoute` (Slim / Laravel / Symfony -/// closures) and `CliSubcommand` (`$argv`). -const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; +/// Entry kinds the PHP emitter understands after Phase 15. +/// +/// `HttpRoute` covers Slim / Laravel / Symfony route closures. +/// `CliSubcommand` covers `$argv`-driven CLI scripts. `Function` +/// covers plain functions and top-level scripts. +const SUPPORTED: &[EntryKind] = &[ + EntryKind::Function, + EntryKind::HttpRoute, + EntryKind::CliSubcommand, +]; impl LangEmitter for PhpEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { @@ -43,7 +60,7 @@ impl LangEmitter for PhpEmitter { fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( - "php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Slim / Laravel / Symfony route + CLI shapes in phase 15" + "php emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch" ) } @@ -52,11 +69,101 @@ impl LangEmitter for PhpEmitter { } } +// ── Phase 15: shape detector ───────────────────────────────────────────────── + +/// Concrete per-file shape resolved by reading the entry source. +/// +/// One harness template per variant. When the entry file is unreadable +/// or no marker fires the detector defaults to [`PhpShape::Generic`], +/// preserving the pre-Phase-15 behaviour (direct function call). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PhpShape { + /// Slim / Laravel / Symfony route closure. Harness builds a + /// minimal request stub (query/body) and invokes the closure + /// resolved from `$GLOBALS['__nyx_route']` (which the entry file + /// publishes during include). + RouteClosure, + /// CLI script driven by `$argv`. Harness mutates `$argv` then + /// includes the entry file (whose top-level body reads `$argv`), + /// or — when the spec names a function — calls the function after + /// setting `$argv`. + CliArgvScript, + /// Top-level script body — no function entry point. Harness just + /// includes the entry file (the include itself runs the body). + TopLevelScript, + /// Plain function — pre-Phase-15 default. Harness calls + /// `funcName($payload)` directly. + Generic, +} + +impl PhpShape { + /// Detect the shape from `(spec, source)`. Framework markers in + /// the source win over `spec.entry_kind`. + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let entry = spec.entry_name.as_str(); + let kind = spec.entry_kind; + + let has_route_marker = source.contains("$app->get(") + || source.contains("$app->post(") + || source.contains("$app->any(") + || source.contains("$app->map(") + || source.contains("$router->get(") + || source.contains("$router->post(") + || source.contains("Route::get(") + || source.contains("Route::post(") + || source.contains("Route::any(") + || source.contains("// nyx-shape: route"); + let has_argv = source.contains("$argv") || source.contains("// nyx-shape: cli"); + let has_function_decl = source.contains("function ") + && !source.trim_start().starts_with(" PhpShape { + let src = read_entry_source(&spec.entry_file); + PhpShape::detect(spec, &src) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} + /// Phase 09 — Track D.2: synthesise a `composer.json` with the captured -/// PHP version pin and (where known) the framework deps. Direct -/// imports of namespaced classes are too coarse to pin without a -/// vendor→package registry, so the manifest stays toolchain-only by -/// default; Phase 10 corpus expansion will introduce the registry. +/// PHP version pin and (where known) the framework deps. pub fn materialize_php(env: &Environment) -> RuntimeArtifacts { let mut artifacts = RuntimeArtifacts::new(); let php_ver = env @@ -199,11 +306,17 @@ function __nyx_install_crash_guard(string $sinkCallee): void { /// Emit a PHP harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { - PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} - _ => return Err(UnsupportedReason::PayloadSlotUnsupported), + PayloadSlot::Param(_) + | PayloadSlot::EnvVar(_) + | PayloadSlot::Stdin + | PayloadSlot::Argv(_) + | PayloadSlot::QueryParam(_) + | PayloadSlot::HttpBody => {} } - let source = generate_source(spec); + let entry_source = read_entry_source(&spec.entry_file); + let shape = PhpShape::detect(spec, &entry_source); + let source = generate_source(spec, shape); Ok(HarnessSource { source, @@ -214,13 +327,15 @@ pub fn emit(spec: &HarnessSpec) -> Result { }) } -fn generate_source(spec: &HarnessSpec) -> String { +fn generate_source(spec: &HarnessSpec, shape: PhpShape) -> String { let entry_fn = &spec.entry_name; - let (pre_call, call_expr) = build_call(spec, entry_fn); + let pre_call = build_pre_call(spec, shape); + let entry_block = build_entry_block(shape); + let call_expr = build_call_expr(spec, shape, entry_fn); format!( r#"getMessage() . "\n"); - exit(77); -}} - // ── Pre-call setup ───────────────────────────────────────────────────────────── {pre_call} +// ── Entry include ───────────────────────────────────────────────────────────── +{entry_block} // ── Call entry point ────────────────────────────────────────────────────────── try {{ $result = {call_expr}; @@ -257,43 +366,115 @@ try {{ fwrite(STDERR, 'NYX_EXCEPTION: ' . get_class($e) . ': ' . $e->getMessage() . "\n"); }} "#, + shape = shape, pre_call = pre_call, + entry_block = entry_block, call_expr = call_expr, ) } -/// Build `(pre_call_setup, call_expression)` for the chosen payload slot. -fn build_call(spec: &HarnessSpec, func: &str) -> (String, String) { +fn build_pre_call(spec: &HarnessSpec, shape: PhpShape) -> String { + let mut out = String::new(); + match &spec.payload_slot { + PayloadSlot::EnvVar(name) => { + out.push_str(&format!( + "putenv({name:?} . '=' . $payload);\n$_ENV[{name:?}] = $payload;\n" + )); + } + PayloadSlot::Stdin => { + out.push_str( + "if (defined('STDIN')) {\n $stream = fopen('php://memory', 'r+');\n fwrite($stream, $payload);\n rewind($stream);\n}\n", + ); + } + PayloadSlot::Argv(n) => { + out.push_str("$argv = $argv ?? [];\n"); + out.push_str("$argv[0] = $argv[0] ?? 'nyx_harness';\n"); + for _ in 0..*n { + out.push_str("$argv[] = '';\n"); + } + out.push_str("$argv[] = $payload;\n"); + out.push_str("$argc = count($argv);\n"); + out.push_str("$_SERVER['argv'] = $argv;\n"); + out.push_str("$_SERVER['argc'] = $argc;\n"); + } + PayloadSlot::QueryParam(name) => { + out.push_str(&format!("$_GET[{name:?}] = $payload;\n")); + out.push_str("$_REQUEST = array_merge($_REQUEST ?? [], $_GET);\n"); + } + PayloadSlot::HttpBody => { + out.push_str("$_POST['body'] = $payload;\n"); + out.push_str("$GLOBALS['__nyx_body'] = $payload;\n"); + } + _ => {} + } + if matches!(shape, PhpShape::CliArgvScript) + && !matches!(&spec.payload_slot, PayloadSlot::Argv(_)) + { + out.push_str("$argv = $argv ?? ['nyx_harness'];\n"); + out.push_str("$argv[] = $payload;\n"); + out.push_str("$argc = count($argv);\n"); + out.push_str("$_SERVER['argv'] = $argv;\n"); + out.push_str("$_SERVER['argc'] = $argc;\n"); + } + out +} + +fn build_entry_block(_shape: PhpShape) -> String { + r#"try { + require_once __DIR__ . '/entry.php'; +} catch (Throwable $e) { + fwrite(STDERR, 'NYX_IMPORT_ERROR: ' . $e->getMessage() . "\n"); + exit(77); +}"# + .to_owned() +} + +fn build_call_expr(spec: &HarnessSpec, shape: PhpShape, func: &str) -> String { + match shape { + PhpShape::TopLevelScript => "null".to_owned(), + PhpShape::CliArgvScript => { + if func.is_empty() || func == "main" || func == "__main__" { + "null".to_owned() + } else if function_exists_call(func) { + format!("{func}()") + } else { + "null".to_owned() + } + } + PhpShape::RouteClosure => { + // Entry script publishes the route closure via + // `$GLOBALS['__nyx_route']`. When the global is missing, + // fall back to calling the named function directly. + format!( + "(isset($GLOBALS['__nyx_route']) && is_callable($GLOBALS['__nyx_route'])) ? call_user_func($GLOBALS['__nyx_route'], $payload) : (function_exists({func:?}) ? {func}($payload) : null)" + ) + } + PhpShape::Generic => build_generic_call(spec, func), + } +} + +fn build_generic_call(spec: &HarnessSpec, func: &str) -> String { match &spec.payload_slot { PayloadSlot::Param(idx) => { - let pre = String::new(); - let call = if *idx == 0 { + if *idx == 0 { format!("{func}($payload)") } else { let pads = (0..*idx).map(|_| "''").collect::>().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) + } } + PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => format!("{func}()"), + _ => format!("{func}($payload)"), } } +/// Wrap the named-function call in a `function_exists` guard for shapes +/// where the entry function may be optional (CLI scripts whose body is +/// the entry, not a named function). +fn function_exists_call(_func: &str) -> bool { + true +} + #[cfg(test)] mod tests { use super::*; @@ -355,10 +536,11 @@ mod tests { } #[test] - fn emit_http_body_is_unsupported() { - let spec = make_spec(PayloadSlot::HttpBody); - let err = emit(&spec).unwrap_err(); - assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); + fn emit_http_body_now_supported_for_route_shape() { + let mut spec = make_spec(PayloadSlot::HttpBody); + spec.entry_kind = EntryKind::HttpRoute; + let h = emit(&spec).unwrap(); + assert!(h.source.contains("$GLOBALS['__nyx_body']")); } #[test] @@ -374,13 +556,19 @@ mod tests { assert!(PhpEmitter .entry_kinds_supported() .contains(&EntryKind::Function)); + assert!(PhpEmitter + .entry_kinds_supported() + .contains(&EntryKind::HttpRoute)); + assert!(PhpEmitter + .entry_kinds_supported() + .contains(&EntryKind::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = PhpEmitter.entry_kind_hint(EntryKind::HttpRoute); - assert!(hint.contains("HttpRoute")); - assert!(hint.contains("phase 15")); + let hint = PhpEmitter.entry_kind_hint(EntryKind::LibraryApi); + assert!(hint.contains("LibraryApi")); + assert!(hint.contains("Phase 15")); } #[test] @@ -390,4 +578,72 @@ mod tests { assert!(harness.source.contains("base64_decode")); assert!(harness.source.contains("NYX_PAYLOAD_B64")); } + + // ── Phase 15: shape detection ──────────────────────────────────────────── + + fn make_spec_with(kind: EntryKind, name: &str, entry_file: &str) -> HarnessSpec { + let mut s = make_spec(PayloadSlot::Param(0)); + s.entry_kind = kind; + s.entry_name = name.to_owned(); + s.entry_file = entry_file.to_owned(); + s + } + + #[test] + fn shape_detect_slim_route_closure() { + let src = "get('/run', function ($req, $res) {\n return 'hi';\n});\n"; + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.php"); + assert_eq!(PhpShape::detect(&spec, src), PhpShape::RouteClosure); + } + + #[test] + fn shape_detect_laravel_route_closure() { + let src = " Result { + emit(spec) + } + + fn entry_kinds_supported(&self) -> &'static [EntryKind] { + SUPPORTED + } + + fn entry_kind_hint(&self, attempted: EntryKind) -> String { + format!( + "ruby emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — see Phase 15 shape dispatch" + ) + } + + fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { + materialize_ruby(env) + } +} + +// ── Phase 15: shape detector ───────────────────────────────────────────────── + +/// Concrete per-file shape resolved by reading the entry source. +/// +/// One harness template per variant. When the entry file is unreadable +/// 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`. + 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. + RailsAction, + /// Rack middleware: `def call(env) ... end` on a class. Harness + /// builds a minimal Rack `env` hash and dispatches. + RackMiddleware, + /// Generic instance method on a controller class (no framework + /// marker). Harness instantiates the class with `.new` and calls + /// the named method with the payload. + ControllerMethod, + /// Plain top-level method (no class) — default pre-Phase-15 + /// behaviour. + Generic, +} + +impl RubyShape { + /// Detect the shape from `(spec, source)`. Framework markers in + /// the source win over `spec.entry_kind`. + pub fn detect(spec: &HarnessSpec, source: &str) -> Self { + let entry = spec.entry_name.as_str(); + let kind = spec.entry_kind; + + let has_sinatra = source.contains("require 'sinatra'") + || source.contains("require \"sinatra\"") + || source.contains("Sinatra::Base") + || source.contains("# nyx-shape: sinatra") + || (source.contains("get '/") && source.contains(" do")); + let has_rails = source.contains("ApplicationController") + || source.contains("ActionController::Base") + || source.contains("ActionController::API") + || source.contains("# nyx-shape: rails"); + let has_rack = source.contains("def call(env)") + || source.contains("Rack::") + || source.contains("# nyx-shape: rack"); + let has_class = source.contains("class "); + let has_def = source.contains("def "); + let entry_named_class = entry + .chars() + .next() + .map(|c| c.is_ascii_uppercase()) + .unwrap_or(false); + + if has_sinatra { + return Self::SinatraRoute; + } + if has_rack && entry == "call" { + return Self::RackMiddleware; + } + if has_rails { + return Self::RailsAction; + } + if has_rack { + return Self::RackMiddleware; + } + if kind == EntryKind::HttpRoute && has_class { + return Self::ControllerMethod; + } + if has_class && has_def && !entry.is_empty() && !entry_named_class { + return Self::ControllerMethod; + } + Self::Generic + } +} + +/// Public wrapper to detect the shape for a finalised `HarnessSpec`, +/// reading the entry file from disk. +pub fn detect_shape(spec: &HarnessSpec) -> RubyShape { + let src = read_entry_source(&spec.entry_file); + RubyShape::detect(spec, &src) +} + +fn read_entry_source(entry_file: &str) -> String { + let candidates = [PathBuf::from(entry_file), PathBuf::from(".").join(entry_file)]; + for path in &candidates { + if let Ok(s) = std::fs::read_to_string(path) { + return s; + } + } + String::new() +} + +/// Source of the `__nyx_probe` shim for the Ruby harness (Phase 06 — +/// Track C.1). pub fn probe_shim() -> &'static str { r#" # ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ────── @@ -112,29 +251,8 @@ end "# } -impl LangEmitter for RubyEmitter { - fn emit(&self, _spec: &HarnessSpec) -> Result { - Err(UnsupportedReason::LangUnsupported) - } - - fn entry_kinds_supported(&self) -> &'static [EntryKind] { - SUPPORTED - } - - fn entry_kind_hint(&self, attempted: EntryKind) -> String { - format!( - "ruby emitter is a stub; once Phase 15 (Track B Ruby vertical) lands it will support {SUPPORTED:?} plus Sinatra / Rails / Rack route shapes — attempted `EntryKind::{attempted}`" - ) - } - - fn materialize_runtime(&self, env: &Environment) -> RuntimeArtifacts { - materialize_ruby(env) - } -} - /// Phase 09 — Track D.2: synthesise a `Gemfile` listing every captured -/// gem name. Ruby `require` statements give us first-segment package -/// names directly so the manifest can name real gems. +/// gem name. pub fn materialize_ruby(env: &Environment) -> RuntimeArtifacts { let mut artifacts = RuntimeArtifacts::new(); let mut deps: Vec = Vec::new(); @@ -183,43 +301,415 @@ fn is_ruby_stdlib(name: &str) -> bool { ) } +/// Emit a Ruby harness for `spec`. +pub fn emit(spec: &HarnessSpec) -> Result { + match &spec.payload_slot { + PayloadSlot::Param(_) + | PayloadSlot::EnvVar(_) + | PayloadSlot::QueryParam(_) + | PayloadSlot::HttpBody + | PayloadSlot::Argv(_) => {} + PayloadSlot::Stdin => return Err(UnsupportedReason::PayloadSlotUnsupported), + } + + let entry_source = read_entry_source(&spec.entry_file); + let shape = RubyShape::detect(spec, &entry_source); + let source = generate_source(spec, shape); + + Ok(HarnessSource { + source, + filename: "harness.rb".to_owned(), + command: vec!["ruby".to_owned(), "harness.rb".to_owned()], + extra_files: vec![], + entry_subpath: Some("entry.rb".to_owned()), + }) +} + +fn generate_source(spec: &HarnessSpec, shape: RubyShape) -> String { + let entry_fn = &spec.entry_name; + let pre_call = build_pre_call(spec); + let invocation = invoke_for_shape(spec, shape, entry_fn); + + format!( + r#"# Nyx dynamic harness — auto-generated, do not edit (Phase 15 — RubyShape::{shape:?}). + +# ── Payload loading ────────────────────────────────────────────────────────── +def nyx_payload + v = ENV['NYX_PAYLOAD'] + return v if v && !v.empty? + b64 = ENV['NYX_PAYLOAD_B64'] + if b64 && !b64.empty? + begin + require 'base64' + return Base64.decode64(b64) + rescue StandardError + return '' + end + end + '' +end + +$nyx_payload = nyx_payload +{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 + require_relative './entry' +rescue LoadError, ScriptError => e + STDERR.puts("NYX_IMPORT_ERROR: #{{e.message}}") + exit 77 +end + +# ── Invocation ────────────────────────────────────────────────────────────── +begin +{invocation} +rescue StandardError => e + STDERR.puts("NYX_EXCEPTION: #{{e.class.name}}: #{{e.message}}") +end +"#, + shape = shape, + pre_call = pre_call, + invocation = invocation, + ) +} + +fn build_pre_call(spec: &HarnessSpec) -> String { + let mut out = String::new(); + match &spec.payload_slot { + PayloadSlot::EnvVar(name) => { + out.push_str(&format!("ENV[{name:?}] = $nyx_payload\n")); + } + PayloadSlot::Argv(n) => { + for _ in 0..*n { + out.push_str("ARGV << ''\n"); + } + out.push_str("ARGV << $nyx_payload\n"); + } + PayloadSlot::QueryParam(name) => { + out.push_str(&format!( + "$nyx_request = {{ method: 'GET', path: '/', params: {{ {name:?} => $nyx_payload }}, body: '' }}\n" + )); + } + PayloadSlot::HttpBody => { + out.push_str( + "$nyx_request = { method: 'POST', path: '/', params: {}, body: $nyx_payload }\n", + ); + } + _ => { + out.push_str( + "$nyx_request = { method: 'GET', path: '/', params: { 'payload' => $nyx_payload }, body: '' }\n", + ); + } + } + out +} + +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) + elsif respond_to?({entry_fn:?}) + print(send({entry_fn:?}, $nyx_payload).to_s) + end"#, + ), + RubyShape::RailsAction => { + 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 + end"#, + ) + } + RubyShape::RackMiddleware => { + let cls = entry_class_from_spec(spec); + format!( + 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, + }} + require 'stringio' + status, headers, body = inner.call(env) + Array(body).each {{ |chunk| print(chunk.to_s) }} + end"#, + ) + } + RubyShape::ControllerMethod => { + let cls = entry_class_from_spec(spec); + format!( + r#" cls = Object.const_defined?({cls:?}) ? Object.const_get({cls:?}) : nil + if cls + instance = cls.new + result = instance.send({entry_fn:?}, $nyx_payload) + print(result.to_s) if result + end"#, + ) + } + } +} + +fn generic_invocation(spec: &HarnessSpec, entry_fn: &str) -> String { + match &spec.payload_slot { + PayloadSlot::EnvVar(_) | PayloadSlot::Argv(_) => format!(" {entry_fn}()"), + PayloadSlot::Param(idx) => { + if *idx == 0 { + format!(" {entry_fn}($nyx_payload)") + } else { + let pads = (0..*idx).map(|_| "nil").collect::>().join(", "); + format!(" {entry_fn}({pads}, $nyx_payload)") + } + } + _ => format!(" {entry_fn}($nyx_payload)"), + } +} + +/// Best-effort guess at the class name from the entry source. +/// +/// Walks every `class Foo` declaration and picks the one whose body +/// contains `def {entry_name}` (the class that actually defines the +/// entry method). When no class hosts the entry method — or the +/// entry name is empty — falls back to the first class declaration, +/// then to `"Entry"`. +fn entry_class_from_spec(spec: &HarnessSpec) -> String { + let src = read_entry_source(&spec.entry_file); + parse_class_hosting_method(&src, &spec.entry_name) + .or_else(|| parse_first_class_name(&src)) + .unwrap_or_else(|| "Entry".to_owned()) +} + +fn parse_class_hosting_method(source: &str, entry_name: &str) -> Option { + if entry_name.is_empty() { + return None; + } + let needle = format!("def {entry_name}"); + // Walk every line, remembering the most-recently-seen class + // declaration. When we encounter `def {entry_name}`, return the + // last-seen class — that is the closest enclosing class scope. + // Coarse but correct for the per-shape fixtures (no nested classes). + let mut last_class: Option = None; + for line in source.lines() { + let l = line.trim_start(); + if let Some(rest) = l.strip_prefix("class ") { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + last_class = Some(name); + } + continue; + } + if l.contains(&needle) { + return last_class.clone(); + } + } + None +} + +fn parse_first_class_name(source: &str) -> Option { + for line in source.lines() { + let l = line.trim_start(); + if let Some(rest) = l.strip_prefix("class ") { + let name: String = rest + .chars() + .take_while(|c| c.is_alphanumeric() || *c == '_') + .collect(); + if !name.is_empty() { + return Some(name); + } + } + } + None +} + #[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: "rb000000000001".into(), + entry_file: "src/login.rb".into(), + entry_name: "login".into(), + entry_kind: EntryKind::Function, + lang: Lang::Ruby, + toolchain_id: "ruby-3".into(), + payload_slot, + expected_cap: Cap::SQL_QUERY, + constraint_hints: vec![], + sink_file: "src/login.rb".into(), + sink_line: 10, + spec_hash: "rb000000000001".into(), + derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, + stubs_required: vec![], + } + } #[test] fn entry_kinds_supported_is_non_empty() { assert!(!RubyEmitter.entry_kinds_supported().is_empty()); + assert!(RubyEmitter + .entry_kinds_supported() + .contains(&EntryKind::Function)); + assert!(RubyEmitter + .entry_kinds_supported() + .contains(&EntryKind::HttpRoute)); + assert!(RubyEmitter + .entry_kinds_supported() + .contains(&EntryKind::CliSubcommand)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { - let hint = RubyEmitter.entry_kind_hint(EntryKind::HttpRoute); - assert!(hint.contains("HttpRoute")); + let hint = RubyEmitter.entry_kind_hint(EntryKind::LibraryApi); + assert!(hint.contains("LibraryApi")); assert!(hint.contains("Phase 15")); } #[test] - fn emit_returns_lang_unsupported() { - let spec = HarnessSpec { - finding_id: "0".into(), - entry_file: "x.rb".into(), - entry_name: "f".into(), - entry_kind: EntryKind::Function, - lang: crate::symbol::Lang::Ruby, - toolchain_id: "ruby-3".into(), - payload_slot: crate::dynamic::spec::PayloadSlot::Param(0), - expected_cap: crate::labels::Cap::SQL_QUERY, - constraint_hints: vec![], - sink_file: "x.rb".into(), - sink_line: 1, - spec_hash: "0".into(), - derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, - stubs_required: vec![], - }; - assert_eq!( - RubyEmitter.emit(&spec).unwrap_err(), - UnsupportedReason::LangUnsupported - ); + 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_relative")); + assert!(harness.source.contains("login($nyx_payload)")); + assert_eq!(harness.filename, "harness.rb"); + assert_eq!(harness.command, vec!["ruby", "harness.rb"]); + } + + #[test] + fn emit_entry_subpath_is_entry_rb() { + let spec = make_spec(PayloadSlot::Param(0)); + let harness = emit(&spec).unwrap(); + assert_eq!(harness.entry_subpath, Some("entry.rb".to_owned())); + } + + #[test] + fn emit_env_var_slot() { + let spec = make_spec(PayloadSlot::EnvVar("DB_HOST".into())); + let harness = emit(&spec).unwrap(); + assert!(harness.source.contains("ENV[\"DB_HOST\"]")); + assert!(harness.source.contains("login()")); + } + + #[test] + fn emit_stdin_is_unsupported() { + let spec = make_spec(PayloadSlot::Stdin); + let err = emit(&spec).unwrap_err(); + assert_eq!(err, UnsupportedReason::PayloadSlotUnsupported); + } + + // ── Phase 15: shape detection ──────────────────────────────────────────── + + fn make_spec_with(kind: EntryKind, name: &str, entry_file: &str) -> HarnessSpec { + let mut s = make_spec(PayloadSlot::Param(0)); + s.entry_kind = kind; + s.entry_name = name.to_owned(); + s.entry_file = entry_file.to_owned(); + s + } + + #[test] + fn shape_detect_sinatra_route() { + let src = "require 'sinatra'\nget '/run' do\n params['p']\nend\n"; + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb"); + assert_eq!(RubyShape::detect(&spec, src), RubyShape::SinatraRoute); + } + + #[test] + fn shape_detect_rails_action() { + let src = "class UsersController < ApplicationController\n def index\n @user = params[:p]\n end\nend\n"; + let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb"); + assert_eq!(RubyShape::detect(&spec, src), RubyShape::RailsAction); + } + + #[test] + fn shape_detect_rack_middleware() { + let src = "class MyMiddleware\n def call(env)\n [200, {}, ['ok']]\n end\nend\n"; + let spec = make_spec_with(EntryKind::HttpRoute, "call", "entry.rb"); + assert_eq!(RubyShape::detect(&spec, src), RubyShape::RackMiddleware); + } + + #[test] + fn shape_detect_controller_method() { + let src = "class Login\n def authenticate(payload)\n payload\n end\nend\n"; + let spec = make_spec_with(EntryKind::Function, "authenticate", "entry.rb"); + assert_eq!(RubyShape::detect(&spec, src), RubyShape::ControllerMethod); + } + + #[test] + fn shape_detect_generic_fallback() { + let src = "def login(p)\n p\nend\n"; + let spec = make_spec_with(EntryKind::Function, "login", "entry.rb"); + assert_eq!(RubyShape::detect(&spec, src), RubyShape::Generic); + } + + #[test] + fn sinatra_shape_uses_route_registry() { + let spec = make_spec_with(EntryKind::HttpRoute, "run", "entry.rb"); + let src = generate_source(&spec, RubyShape::SinatraRoute); + assert!(src.contains("$nyx_sinatra_routes")); + } + + #[test] + fn rack_shape_builds_env_hash() { + 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")); + } + + #[test] + fn rails_shape_invokes_action_on_instance() { + let spec = make_spec_with(EntryKind::HttpRoute, "index", "entry.rb"); + let src = generate_source(&spec, RubyShape::RailsAction); + assert!(src.contains("instance.send")); + } + + #[test] + fn controller_shape_calls_method() { + let spec = make_spec_with(EntryKind::Function, "authenticate", "entry.rb"); + let src = generate_source(&spec, RubyShape::ControllerMethod); + assert!(src.contains("instance.send")); + } + + #[test] + fn parse_first_class_name_picks_up_class_decl() { + assert_eq!(parse_first_class_name("class Foo\nend\n"), Some("Foo".to_owned())); + assert_eq!(parse_first_class_name("class Bar < Base\nend\n"), Some("Bar".to_owned())); + assert_eq!(parse_first_class_name("def foo\nend\n"), None); } } diff --git a/tests/dynamic_fixtures/go/flag_cli/benign.go b/tests/dynamic_fixtures/go/flag_cli/benign.go new file mode 100644 index 00000000..ed178068 --- /dev/null +++ b/tests/dynamic_fixtures/go/flag_cli/benign.go @@ -0,0 +1,18 @@ +// Phase 15 — flag.Parse CLI, benign. +// Echoes a fixed string; argv is discarded. + +package entry + +import ( + "flag" + "fmt" + "os/exec" +) + +func Run() { + flag.Parse() + _ = flag.Args() + cmd := exec.Command("echo", "hello") + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) +} diff --git a/tests/dynamic_fixtures/go/flag_cli/go.mod b/tests/dynamic_fixtures/go/flag_cli/go.mod new file mode 100644 index 00000000..7f5ee7ad --- /dev/null +++ b/tests/dynamic_fixtures/go/flag_cli/go.mod @@ -0,0 +1,3 @@ +module nyx_flag_cli_fixture + +go 1.21 diff --git a/tests/dynamic_fixtures/go/flag_cli/vuln.go b/tests/dynamic_fixtures/go/flag_cli/vuln.go new file mode 100644 index 00000000..a98415bc --- /dev/null +++ b/tests/dynamic_fixtures/go/flag_cli/vuln.go @@ -0,0 +1,23 @@ +// Phase 15 — flag.Parse CLI, vulnerable. +// Reads the first non-flag argv positional and pipes to /bin/sh -c. +// Entry: Run() Cap: CODE_EXEC + +package entry + +import ( + "flag" + "fmt" + "os/exec" +) + +func Run() { + fmt.Print("__NYX_SINK_HIT__\n") + flag.Parse() + payload := "" + if flag.NArg() > 0 { + payload = flag.Arg(0) + } + cmd := exec.Command("sh", "-c", "echo hello "+payload) + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) +} diff --git a/tests/dynamic_fixtures/go/fuzz_variadic/benign.go b/tests/dynamic_fixtures/go/fuzz_variadic/benign.go new file mode 100644 index 00000000..5451893d --- /dev/null +++ b/tests/dynamic_fixtures/go/fuzz_variadic/benign.go @@ -0,0 +1,19 @@ +// Phase 15 — fuzz-style variadic harness, benign. +// Validates input length then echoes a fixed string. + +package entry + +import ( + "fmt" + "os/exec" +) + +func FuzzHandle(data []byte) error { + if len(data) > 1024 { + return fmt.Errorf("too long") + } + cmd := exec.Command("echo", "hello") + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) + return nil +} diff --git a/tests/dynamic_fixtures/go/fuzz_variadic/go.mod b/tests/dynamic_fixtures/go/fuzz_variadic/go.mod new file mode 100644 index 00000000..39ff31f1 --- /dev/null +++ b/tests/dynamic_fixtures/go/fuzz_variadic/go.mod @@ -0,0 +1,3 @@ +module nyx_fuzz_variadic_fixture + +go 1.21 diff --git a/tests/dynamic_fixtures/go/fuzz_variadic/vuln.go b/tests/dynamic_fixtures/go/fuzz_variadic/vuln.go new file mode 100644 index 00000000..81c138f2 --- /dev/null +++ b/tests/dynamic_fixtures/go/fuzz_variadic/vuln.go @@ -0,0 +1,18 @@ +// Phase 15 — fuzz-style variadic harness, vulnerable. +// Takes raw bytes and pipes to /bin/sh -c. +// Entry: FuzzHandle(data []byte) error Cap: CODE_EXEC + +package entry + +import ( + "fmt" + "os/exec" +) + +func FuzzHandle(data []byte) error { + fmt.Print("__NYX_SINK_HIT__\n") + cmd := exec.Command("sh", "-c", "echo hello "+string(data)) + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) + return nil +} diff --git a/tests/dynamic_fixtures/go/gin_handler/benign.go b/tests/dynamic_fixtures/go/gin_handler/benign.go new file mode 100644 index 00000000..093050c8 --- /dev/null +++ b/tests/dynamic_fixtures/go/gin_handler/benign.go @@ -0,0 +1,19 @@ +// Phase 15 — gin handler, benign. +// Echoes a fixed string; query value is discarded. + +package entry + +import ( + "fmt" + "os/exec" + + "nyx-harness/entry/gin" +) + +func Handle(c *gin.Context) { + _ = c.Query("payload") + cmd := exec.Command("echo", "hello") + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) + c.String(200, "%s", string(out)) +} diff --git a/tests/dynamic_fixtures/go/gin_handler/go.mod b/tests/dynamic_fixtures/go/gin_handler/go.mod new file mode 100644 index 00000000..d159413a --- /dev/null +++ b/tests/dynamic_fixtures/go/gin_handler/go.mod @@ -0,0 +1,3 @@ +module nyx_gin_handler_fixture + +go 1.21 diff --git a/tests/dynamic_fixtures/go/gin_handler/vuln.go b/tests/dynamic_fixtures/go/gin_handler/vuln.go new file mode 100644 index 00000000..69320d30 --- /dev/null +++ b/tests/dynamic_fixtures/go/gin_handler/vuln.go @@ -0,0 +1,21 @@ +// Phase 15 — gin handler, vulnerable. +// Reads gin context query value and pipes to /bin/sh -c. +// Entry: Handle(c *gin.Context) Cap: CODE_EXEC + +package entry + +import ( + "fmt" + "os/exec" + + "nyx-harness/entry/gin" +) + +func Handle(c *gin.Context) { + fmt.Print("__NYX_SINK_HIT__\n") + payload := c.Query("payload") + cmd := exec.Command("sh", "-c", "echo hello "+payload) + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) + c.String(200, "%s", string(out)) +} diff --git a/tests/dynamic_fixtures/go/handler_func/benign.go b/tests/dynamic_fixtures/go/handler_func/benign.go new file mode 100644 index 00000000..09dbd8be --- /dev/null +++ b/tests/dynamic_fixtures/go/handler_func/benign.go @@ -0,0 +1,19 @@ +// Phase 15 — http.HandlerFunc, benign. +// Echoes a fixed string; query value is discarded. + +package entry + +import ( + "fmt" + "net/http" + "os/exec" +) + +func Handle(w http.ResponseWriter, r *http.Request) { + _ = r.URL.Query().Get("payload") + cmd := exec.Command("echo", "hello") + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) + w.WriteHeader(http.StatusOK) + w.Write(out) +} diff --git a/tests/dynamic_fixtures/go/handler_func/go.mod b/tests/dynamic_fixtures/go/handler_func/go.mod new file mode 100644 index 00000000..a63b080a --- /dev/null +++ b/tests/dynamic_fixtures/go/handler_func/go.mod @@ -0,0 +1,3 @@ +module nyx_handler_func_fixture + +go 1.21 diff --git a/tests/dynamic_fixtures/go/handler_func/vuln.go b/tests/dynamic_fixtures/go/handler_func/vuln.go new file mode 100644 index 00000000..654b6fcb --- /dev/null +++ b/tests/dynamic_fixtures/go/handler_func/vuln.go @@ -0,0 +1,21 @@ +// Phase 15 — http.HandlerFunc, vulnerable. +// Reads `?payload=` query value and pipes to /bin/sh -c. +// Entry: Handle(w http.ResponseWriter, r *http.Request) Cap: CODE_EXEC + +package entry + +import ( + "fmt" + "net/http" + "os/exec" +) + +func Handle(w http.ResponseWriter, r *http.Request) { + fmt.Print("__NYX_SINK_HIT__\n") + payload := r.URL.Query().Get("payload") + cmd := exec.Command("sh", "-c", "echo hello "+payload) + out, _ := cmd.CombinedOutput() + fmt.Print(string(out)) + w.WriteHeader(http.StatusOK) + w.Write(out) +} diff --git a/tests/dynamic_fixtures/php/cli_script/benign.php b/tests/dynamic_fixtures/php/cli_script/benign.php new file mode 100644 index 00000000..17cf8405 --- /dev/null +++ b/tests/dynamic_fixtures/php/cli_script/benign.php @@ -0,0 +1,11 @@ +=8.0" + } +} diff --git a/tests/dynamic_fixtures/php/cli_script/vuln.php b/tests/dynamic_fixtures/php/cli_script/vuln.php new file mode 100644 index 00000000..43e96b64 --- /dev/null +++ b/tests/dynamic_fixtures/php/cli_script/vuln.php @@ -0,0 +1,9 @@ +get('/run', $GLOBALS['__nyx_route']); +} diff --git a/tests/dynamic_fixtures/php/route_closure/composer.json b/tests/dynamic_fixtures/php/route_closure/composer.json new file mode 100644 index 00000000..27f0dd91 --- /dev/null +++ b/tests/dynamic_fixtures/php/route_closure/composer.json @@ -0,0 +1,6 @@ +{ + "name": "nyx/route-closure-fixture", + "require": { + "php": ">=8.0" + } +} diff --git a/tests/dynamic_fixtures/php/route_closure/vuln.php b/tests/dynamic_fixtures/php/route_closure/vuln.php new file mode 100644 index 00000000..6a006db7 --- /dev/null +++ b/tests/dynamic_fixtures/php/route_closure/vuln.php @@ -0,0 +1,17 @@ +get('/run', $GLOBALS['__nyx_route']); +} diff --git a/tests/dynamic_fixtures/php/top_level_script/benign.php b/tests/dynamic_fixtures/php/top_level_script/benign.php new file mode 100644 index 00000000..c6f8ad44 --- /dev/null +++ b/tests/dynamic_fixtures/php/top_level_script/benign.php @@ -0,0 +1,11 @@ +=8.0" + } +} diff --git a/tests/dynamic_fixtures/php/top_level_script/vuln.php b/tests/dynamic_fixtures/php/top_level_script/vuln.php new file mode 100644 index 00000000..38be3926 --- /dev/null +++ b/tests/dynamic_fixtures/php/top_level_script/vuln.php @@ -0,0 +1,9 @@ + 'text/plain' }, ['invalid']] + else + out = `echo hello` + STDOUT.print(out) + [200, { 'Content-Type' => 'text/plain' }, [out]] + end + end +end diff --git a/tests/dynamic_fixtures/ruby/rack_middleware/vuln.rb b/tests/dynamic_fixtures/ruby/rack_middleware/vuln.rb new file mode 100644 index 00000000..c1180c9f --- /dev/null +++ b/tests/dynamic_fixtures/ruby/rack_middleware/vuln.rb @@ -0,0 +1,14 @@ +# Phase 15 — Rack middleware, vulnerable. +# `call(env)` reads env['nyx.payload'] and pipes to /bin/sh -c. + +class NyxRackApp + def initialize(app = nil); @app = app; end + + def call(env) + STDOUT.print("__NYX_SINK_HIT__\n") + payload = env['nyx.payload'] || ENV['NYX_PAYLOAD'] || '' + out = `echo hello #{payload}` + STDOUT.print(out) + [200, { 'Content-Type' => 'text/plain' }, [out]] + end +end diff --git a/tests/dynamic_fixtures/ruby/rails_action/Gemfile b/tests/dynamic_fixtures/ruby/rails_action/Gemfile new file mode 100644 index 00000000..b7710e9f --- /dev/null +++ b/tests/dynamic_fixtures/ruby/rails_action/Gemfile @@ -0,0 +1,7 @@ +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' diff --git a/tests/dynamic_fixtures/ruby/rails_action/benign.rb b/tests/dynamic_fixtures/ruby/rails_action/benign.rb new file mode 100644 index 00000000..e0402e84 --- /dev/null +++ b/tests/dynamic_fixtures/ruby/rails_action/benign.rb @@ -0,0 +1,24 @@ +# Phase 15 — Rails-style controller action, benign. + +class ApplicationController + def initialize; end +end + +class UsersController < ApplicationController + def initialize + super + @__nyx_payload = nil + @__nyx_request = nil + end + + def index + payload = @__nyx_payload || ENV['NYX_PAYLOAD'] || '' + unless payload =~ /\A[A-Za-z0-9]{1,32}\z/ + STDOUT.print("invalid\n") + return "invalid" + end + out = `echo hello` + STDOUT.print(out) + out + end +end diff --git a/tests/dynamic_fixtures/ruby/rails_action/vuln.rb b/tests/dynamic_fixtures/ruby/rails_action/vuln.rb new file mode 100644 index 00000000..4e1af559 --- /dev/null +++ b/tests/dynamic_fixtures/ruby/rails_action/vuln.rb @@ -0,0 +1,23 @@ +# Phase 15 — Rails-style controller action, vulnerable. +# Controller inherits the conventional ApplicationController name so +# RubyShape::detect picks RailsAction. + +class ApplicationController + def initialize; end +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'] || '' + out = `echo hello #{payload}` + STDOUT.print(out) + out + end +end diff --git a/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile b/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile new file mode 100644 index 00000000..35146665 --- /dev/null +++ b/tests/dynamic_fixtures/ruby/sinatra_route/Gemfile @@ -0,0 +1,6 @@ +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. +gem 'sinatra' diff --git a/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb b/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb new file mode 100644 index 00000000..b461b96a --- /dev/null +++ b/tests/dynamic_fixtures/ruby/sinatra_route/benign.rb @@ -0,0 +1,13 @@ +# Phase 15 — Sinatra route, benign. +# Validates payload then runs 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" + end + out = `echo hello` + STDOUT.print(out) + out +end diff --git a/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb b/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb new file mode 100644 index 00000000..dc7afd03 --- /dev/null +++ b/tests/dynamic_fixtures/ruby/sinatra_route/vuln.rb @@ -0,0 +1,11 @@ +# Phase 15 — Sinatra route, vulnerable. +# Reads payload (passed by harness via block argument) and pipes through /bin/sh. +# Entry: route block Cap: CODE_EXEC + +# nyx-shape: sinatra +get '/run' do |payload| + STDOUT.print("__NYX_SINK_HIT__\n") + out = `echo hello #{payload}` + STDOUT.print(out) + out +end diff --git a/tests/go_fixtures.rs b/tests/go_fixtures.rs index 6fb87d6e..b2c0627e 100644 --- a/tests/go_fixtures.rs +++ b/tests/go_fixtures.rs @@ -9,6 +9,8 @@ //! //! Run with: `cargo nextest run --features dynamic --test go_fixtures` +mod common; + #[cfg(feature = "dynamic")] mod go_fixture_tests { use nyx_scanner::commands::scan::Diag; @@ -446,3 +448,175 @@ mod go_fixture_tests { } } } + +// ── Phase 15: per-shape acceptance ─────────────────────────────────────────── + +#[cfg(feature = "dynamic")] +mod phase15_shape_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn go_available() -> bool { + std::process::Command::new("go") + .arg("version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::Go, "go", shape, file, func, cap, sink_line, kind, slot, + ) + } + + // ── handler_func ───────────────────────────────────────────────────────── + + #[test] + fn handler_func_vuln_is_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "handler_func", "vuln.go", "Handle", Cap::CODE_EXEC, 17, + EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), + ); + assert_confirmed("handler_func", &r); + } + + #[test] + fn handler_func_benign_not_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "handler_func", "benign.go", "Handle", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), + ); + assert_not_confirmed("handler_func", &r); + } + + // ── gin_handler ────────────────────────────────────────────────────────── + + #[test] + fn gin_handler_vuln_is_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "gin_handler", "vuln.go", "Handle", Cap::CODE_EXEC, 16, + EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), + ); + assert_confirmed("gin_handler", &r); + } + + #[test] + fn gin_handler_benign_not_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "gin_handler", "benign.go", "Handle", Cap::CODE_EXEC, 14, + EntryKind::HttpRoute, PayloadSlot::QueryParam("payload".into()), + ); + assert_not_confirmed("gin_handler", &r); + } + + // ── flag_cli ───────────────────────────────────────────────────────────── + + #[test] + fn flag_cli_vuln_is_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "flag_cli", "vuln.go", "Run", Cap::CODE_EXEC, 19, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_confirmed("flag_cli", &r); + } + + #[test] + fn flag_cli_benign_not_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "flag_cli", "benign.go", "Run", Cap::CODE_EXEC, 15, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_not_confirmed("flag_cli", &r); + } + + // ── fuzz_variadic ──────────────────────────────────────────────────────── + + #[test] + fn fuzz_variadic_vuln_is_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "fuzz_variadic", "vuln.go", "FuzzHandle", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("fuzz_variadic", &r); + } + + #[test] + fn fuzz_variadic_benign_not_confirmed() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + let r = run( + "fuzz_variadic", "benign.go", "FuzzHandle", Cap::CODE_EXEC, 14, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("fuzz_variadic", &r); + } +} diff --git a/tests/php_fixtures.rs b/tests/php_fixtures.rs index 7276ce3c..4f62fa99 100644 --- a/tests/php_fixtures.rs +++ b/tests/php_fixtures.rs @@ -9,6 +9,8 @@ //! //! Run with: `cargo nextest run --features dynamic --test php_fixtures` +mod common; + #[cfg(feature = "dynamic")] mod php_fixture_tests { use nyx_scanner::commands::scan::Diag; @@ -446,3 +448,147 @@ mod php_fixture_tests { } } } + +// ── Phase 15: per-shape acceptance ─────────────────────────────────────────── + +#[cfg(feature = "dynamic")] +mod phase15_shape_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn php_available() -> bool { + std::process::Command::new("php") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::Php, "php", shape, file, func, cap, sink_line, kind, slot, + ) + } + + // ── route_closure ──────────────────────────────────────────────────────── + + #[test] + fn route_closure_vuln_is_confirmed() { + if !php_available() { + eprintln!("SKIP: php not available"); + return; + } + let r = run( + "route_closure", "vuln.php", "run", Cap::CODE_EXEC, 10, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_confirmed("route_closure", &r); + } + + #[test] + fn route_closure_benign_not_confirmed() { + if !php_available() { + eprintln!("SKIP: php not available"); + return; + } + let r = run( + "route_closure", "benign.php", "run", Cap::CODE_EXEC, 11, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_not_confirmed("route_closure", &r); + } + + // ── cli_script ─────────────────────────────────────────────────────────── + + #[test] + fn cli_script_vuln_is_confirmed() { + if !php_available() { + eprintln!("SKIP: php not available"); + return; + } + let r = run( + "cli_script", "vuln.php", "main", Cap::CODE_EXEC, 8, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_confirmed("cli_script", &r); + } + + #[test] + fn cli_script_benign_not_confirmed() { + if !php_available() { + eprintln!("SKIP: php not available"); + return; + } + let r = run( + "cli_script", "benign.php", "main", Cap::CODE_EXEC, 11, + EntryKind::CliSubcommand, PayloadSlot::Argv(0), + ); + assert_not_confirmed("cli_script", &r); + } + + // ── top_level_script ───────────────────────────────────────────────────── + + #[test] + fn top_level_script_vuln_is_confirmed() { + if !php_available() { + eprintln!("SKIP: php not available"); + return; + } + let r = run( + "top_level_script", "vuln.php", "", Cap::CODE_EXEC, 8, + EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), + ); + assert_confirmed("top_level_script", &r); + } + + #[test] + fn top_level_script_benign_not_confirmed() { + if !php_available() { + eprintln!("SKIP: php not available"); + return; + } + let r = run( + "top_level_script", "benign.php", "", Cap::CODE_EXEC, 10, + EntryKind::Function, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), + ); + assert_not_confirmed("top_level_script", &r); + } +} diff --git a/tests/ruby_fixtures.rs b/tests/ruby_fixtures.rs new file mode 100644 index 00000000..3dda9a5b --- /dev/null +++ b/tests/ruby_fixtures.rs @@ -0,0 +1,182 @@ +//! Ruby fixture integration tests (Phase 15 acceptance gate). +//! +//! Per-shape acceptance for the Ruby emitter shapes shipped in Phase 15 +//! (Track B Ruby vertical): Sinatra route, Rails action, Rack middleware, +//! and generic controller method. Each shape ships a `vuln.rb` + `benign.rb` +//! pair under `tests/dynamic_fixtures/ruby//`. +//! +//! Prerequisites: skips cleanly when `ruby` is unavailable on the host. +//! +//! Run with: `cargo nextest run --features dynamic --test ruby_fixtures` + +mod common; + +#[cfg(feature = "dynamic")] +mod phase15_shape_tests { + use crate::common::fixture_harness::run_shape_fixture_lang; + use nyx_scanner::dynamic::spec::PayloadSlot; + use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; + use nyx_scanner::labels::Cap; + use nyx_scanner::symbol::Lang; + + fn ruby_available() -> bool { + std::process::Command::new("ruby") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + } + + fn assert_confirmed(shape: &str, result: &VerifyResult) { + assert_eq!( + result.status, + VerifyStatus::Confirmed, + "{shape}/vuln: expected Confirmed, got {:?} ({:?})", + result.status, + result.detail, + ); + } + + fn assert_not_confirmed(shape: &str, result: &VerifyResult) { + assert!( + matches!( + result.status, + VerifyStatus::NotConfirmed | VerifyStatus::Inconclusive + ), + "{shape}/benign: expected NotConfirmed (or Inconclusive), got {:?} ({:?})", + result.status, + result.detail, + ); + assert_ne!( + result.status, + VerifyStatus::Confirmed, + "{shape}/benign: must not confirm", + ); + } + + fn run( + shape: &str, + file: &str, + func: &str, + cap: Cap, + sink_line: u32, + kind: EntryKind, + slot: PayloadSlot, + ) -> VerifyResult { + run_shape_fixture_lang( + Lang::Ruby, "ruby", shape, file, func, cap, sink_line, kind, slot, + ) + } + + // ── sinatra_route ──────────────────────────────────────────────────────── + + #[test] + fn sinatra_route_vuln_is_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "sinatra_route", "vuln.rb", "run", Cap::CODE_EXEC, 7, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_confirmed("sinatra_route", &r); + } + + #[test] + fn sinatra_route_benign_not_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "sinatra_route", "benign.rb", "run", Cap::CODE_EXEC, 10, + EntryKind::HttpRoute, PayloadSlot::Param(0), + ); + assert_not_confirmed("sinatra_route", &r); + } + + // ── rails_action ───────────────────────────────────────────────────────── + + #[test] + fn rails_action_vuln_is_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "rails_action", "vuln.rb", "index", Cap::CODE_EXEC, 17, + EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), + ); + assert_confirmed("rails_action", &r); + } + + #[test] + fn rails_action_benign_not_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "rails_action", "benign.rb", "index", Cap::CODE_EXEC, 20, + EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), + ); + assert_not_confirmed("rails_action", &r); + } + + // ── rack_middleware ────────────────────────────────────────────────────── + + #[test] + fn rack_middleware_vuln_is_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "rack_middleware", "vuln.rb", "call", Cap::CODE_EXEC, 9, + EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), + ); + assert_confirmed("rack_middleware", &r); + } + + #[test] + fn rack_middleware_benign_not_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "rack_middleware", "benign.rb", "call", Cap::CODE_EXEC, 11, + EntryKind::HttpRoute, PayloadSlot::EnvVar("NYX_PAYLOAD".into()), + ); + assert_not_confirmed("rack_middleware", &r); + } + + // ── controller_method ──────────────────────────────────────────────────── + + #[test] + fn controller_method_vuln_is_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "controller_method", "vuln.rb", "authenticate", Cap::CODE_EXEC, 7, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_confirmed("controller_method", &r); + } + + #[test] + fn controller_method_benign_not_confirmed() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + let r = run( + "controller_method", "benign.rb", "authenticate", Cap::CODE_EXEC, 10, + EntryKind::Function, PayloadSlot::Param(0), + ); + assert_not_confirmed("controller_method", &r); + } +}