//! 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::PayloadSlotUnsupported`. //! //! Build: no compilation step. Command is `node harness.js`. //! Build container: `nyx-build-node:{toolchain_id}` (deferred; §19.1). use crate::dynamic::lang::{HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; /// Zero-sized [`LangEmitter`] handle for JavaScript / TypeScript (one /// emitter, both langs share the same Node.js dispatch). Method bodies /// delegate to the existing free functions in this module. pub struct JavaScriptEmitter; /// Entry kinds the JS / TS emitter currently understands. Extended in /// Phase 13 (Track B JS + TS vertical) to include `HttpRoute` (Express / /// Koa / Next), `CliSubcommand`, etc. const SUPPORTED: &[EntryKind] = &[EntryKind::Function]; impl LangEmitter for JavaScriptEmitter { fn emit(&self, spec: &HarnessSpec) -> Result { emit(spec) } fn entry_kinds_supported(&self) -> &'static [EntryKind] { SUPPORTED } fn entry_kind_hint(&self, attempted: EntryKind) -> String { format!( "javascript / typescript emitter supports {SUPPORTED:?}; this finding's enclosing context is `EntryKind::{attempted}` — Track B will add Express / Koa / Next shapes in phase 13" ) } } /// Emit a Node.js harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { match &spec.payload_slot { PayloadSlot::Param(_) | PayloadSlot::EnvVar(_) | PayloadSlot::Stdin => {} _ => return Err(UnsupportedReason::PayloadSlotUnsupported), } 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::>().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 { // The harness always `require('./entry')` because `entry_module_filename` // unconditionally copies the source to `entry.js` in the workdir. Keeping // these two helpers in sync prevents a "Cannot find module" import error // when the fixture's on-disk filename is anything other than `entry.js`. "entry".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(), derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, } } #[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::PayloadSlotUnsupported); } #[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_kinds_supported_is_non_empty() { assert!(!JavaScriptEmitter.entry_kinds_supported().is_empty()); assert!(JavaScriptEmitter .entry_kinds_supported() .contains(&EntryKind::Function)); } #[test] fn entry_kind_hint_names_attempted_and_phase() { let hint = JavaScriptEmitter.entry_kind_hint(EntryKind::HttpRoute); assert!(hint.contains("HttpRoute")); assert!(hint.contains("phase 13")); } #[test] fn entry_module_name_is_always_entry_to_match_copy_destination() { // `copy_entry_file` (via `entry_module_filename`) stages every fixture // at `workdir/entry.js`, so `require('./entry')` is the only path the // harness can use without missing-module errors at runtime, regardless // of the source file's original name. assert_eq!(entry_module_name("src/handlers/login.js"), "entry"); assert_eq!(entry_module_name("app.ts"), "entry"); assert_eq!(entry_module_name("handler.mjs"), "entry"); assert_eq!(entry_module_name("no_ext"), "entry"); } }