nyx/src/dynamic/lang/javascript.rs

330 lines
12 KiB
Rust
Raw Normal View History

//! 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<HarnessSource, UnsupportedReason> {
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"
)
}
}
/// Source of the `__nyx_probe` shim for the Node.js harness.
///
/// Defined once here so both [`JavaScriptEmitter`] and
/// [`crate::dynamic::lang::typescript::TypeScriptEmitter`] reuse the same
/// JSON-emit format. Writes a single [`crate::dynamic::probe::SinkProbe`]
/// JSON line to `NYX_PROBE_PATH` per call; no-op when the env var is
/// unset.
pub fn probe_shim() -> &'static str {
r#"
// ── __nyx_probe shim (Phase 06 — Track C.1) ──────────────────────────────────
function __nyx_probe(sinkCallee, ...args) {
const _fs = require('fs');
const _p = process.env.NYX_PROBE_PATH;
if (!_p) return;
const _ser = args.map(function (a) {
if (a && typeof a === 'object' && (a instanceof Buffer || a instanceof Uint8Array)) {
return { kind: 'Bytes', value: Array.from(a) };
}
if (typeof a === 'number' && Number.isInteger(a)) {
return { kind: 'Int', value: a };
}
if (typeof a === 'boolean') {
return { kind: 'Int', value: a ? 1 : 0 };
}
return { kind: 'String', value: String(a) };
});
const _rec = {
sink_callee: String(sinkCallee),
args: _ser,
captured_at_ns: Number(process.hrtime.bigint()),
payload_id: String(process.env.NYX_PAYLOAD_ID || ''),
};
try {
_fs.appendFileSync(_p, JSON.stringify(_rec) + '\n');
} catch (e) {
// best-effort: probe channel write failure is non-fatal.
}
}
"#
}
/// 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::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);
let probe = probe_shim();
format!(
r#"'use strict';
// Nyx dynamic harness — auto-generated, do not edit.
{probe}
// ── 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,
probe = probe,
)
}
/// 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 {
// 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");
}
}