diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index ba993594..f01b4335 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -160,7 +160,15 @@ fn is_rust_stdlib(name: &str) -> bool { /// the shim's only dep on `std`; matches the /// [`crate::dynamic::probe::SinkProbe`] wire format. pub fn probe_shim() -> &'static str { - r#" + // Raw-string delimiter is `r##"..."##` (not `r#"..."#`) so the + // body can contain literal `"# ...` byte sequences without + // terminating the raw string early. The Phase 10 stub recorder + // helpers below emit hash-prefixed log lines (`"# method: ..."`) + // that would otherwise close `r#"..."#` at the first `"#`. Same + // workaround as Java's shim raw string (session 0018) — defensive + // so future shim extensions that introduce `"#` substrings drop + // in without further bumps. + r##" // ── __nyx_probe shim (Phase 06 — Track C.1, Phase 08 — Track C.4 + C.5) ────── #[allow(dead_code)] const __NYX_DENY_SUBSTRINGS: &[&str] = &[ @@ -352,7 +360,90 @@ fn __nyx_install_crash_guard(sink_callee: &'static str) { #[cfg(not(unix))] #[allow(dead_code)] fn __nyx_install_crash_guard(_sink_callee: &'static str) {} -"# + +// Phase 10 (Track D.3) SQL recording helper. Mirrors the +// Python/Node/PHP/Go/Ruby/Java siblings: when the verifier spawned a +// SqlStub it publishes the side-channel log path on `NYX_SQL_LOG`; a +// sink callsite whose query never reaches the on-the-wire SQLite +// engine can call this helper to surface the attempted query. Hash- +// prefixed detail lines followed by the query line so the host-side +// merger parses every language stream identically. No-op when the +// env var is unset. +#[allow(dead_code)] +fn __nyx_stub_sql_record(query: &str, detail: &[(&str, &str)]) { + use std::io::Write; + let path = match std::env::var("NYX_SQL_LOG") { + Ok(p) => p, + Err(_) => return, + }; + let mut buf = String::with_capacity(128); + for (k, v) in detail { + buf.push_str("# "); + buf.push_str(k); + buf.push_str(": "); + buf.push_str(v); + buf.push('\n'); + } + buf.push_str(query); + if !query.ends_with('\n') { + buf.push('\n'); + } + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = f.write_all(buf.as_bytes()); + } +} + +// Phase 10 (Track D.3) HTTP recording helper. When the verifier +// spawned an HttpStub it publishes the side-channel log path on +// `NYX_HTTP_LOG`; a sink callsite whose outbound request never +// reaches the on-the-wire listener (DNS-mocked, network-isolated +// sandbox, pre-flight check) can call this helper to surface the +// attempted call. Format matches the SQL helper so the host-side +// merger parses both streams identically. No-op when the env var +// is unset. +#[allow(dead_code)] +fn __nyx_stub_http_record(method: &str, url: &str, body: Option<&str>, detail: &[(&str, &str)]) { + use std::io::Write; + let path = match std::env::var("NYX_HTTP_LOG") { + Ok(p) => p, + Err(_) => return, + }; + let mut buf = String::with_capacity(128); + buf.push_str("# method: "); + buf.push_str(method); + buf.push('\n'); + buf.push_str("# url: "); + buf.push_str(url); + buf.push('\n'); + if let Some(b) = body { + buf.push_str("# body: "); + buf.push_str(b); + buf.push('\n'); + } + for (k, v) in detail { + buf.push_str("# "); + buf.push_str(k); + buf.push_str(": "); + buf.push_str(v); + buf.push('\n'); + } + buf.push_str(method); + buf.push(' '); + buf.push_str(url); + buf.push('\n'); + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&path) + { + let _ = f.write_all(buf.as_bytes()); + } +} +"## } // ── Phase 16: shape detector ───────────────────────────────────────────────── @@ -927,6 +1018,34 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_recorders() { + // Phase 10 (Track D.3): the Rust probe shim ships the SQL + + // HTTP recording helpers alongside the existing crash-guard / + // probe-emit machinery so a sink callsite can surface + // attempted boundary calls when the on-the-wire stub never + // sees them. Asserts the helper names + the `NYX_*_LOG` env + // hooks are present so future raw-string-delimiter regressions + // (`r#"..."#` → `r##"..."##`) get caught early. + let shim = probe_shim(); + assert!( + shim.contains("fn __nyx_stub_sql_record("), + "Rust probe shim must define __nyx_stub_sql_record", + ); + assert!( + shim.contains("fn __nyx_stub_http_record("), + "Rust probe shim must define __nyx_stub_http_record", + ); + assert!( + shim.contains("NYX_SQL_LOG"), + "SQL recorder must read NYX_SQL_LOG", + ); + assert!( + shim.contains("NYX_HTTP_LOG"), + "HTTP recorder must read NYX_HTTP_LOG", + ); + } + #[test] fn chain_step_emits_cargo_toml_with_libc_dep() { let step = chain_step(None); diff --git a/tests/c_fixtures.rs b/tests/c_fixtures.rs index aa67f2b3..19e52e37 100644 --- a/tests/c_fixtures.rs +++ b/tests/c_fixtures.rs @@ -15,20 +15,16 @@ mod common; #[cfg(feature = "dynamic")] mod c_fixture_tests { - use crate::common::fixture_harness::run_shape_fixture_lang; + use crate::common::fixture_harness::{run_shape_fixture_lang_or_skip, Prerequisite}; use nyx_scanner::dynamic::spec::PayloadSlot; use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; use nyx_scanner::labels::Cap; use nyx_scanner::symbol::Lang; - fn cc_available() -> bool { - let bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); - std::process::Command::new(&bin) - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } + const CC_REQ: &[Prerequisite] = &[Prerequisite::CommandAvailableEnvOverride { + env_var: "NYX_CC_BIN", + default: "cc", + }]; fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( @@ -57,6 +53,7 @@ mod c_fixture_tests { ); } + #[allow(clippy::too_many_arguments)] fn run( shape: &str, file: &str, @@ -65,9 +62,9 @@ mod c_fixture_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( - Lang::C, "c", shape, file, func, cap, sink_line, kind, slot, + ) -> Option { + run_shape_fixture_lang_or_skip( + CC_REQ, Lang::C, "c", shape, file, func, cap, sink_line, kind, slot, ) } @@ -75,27 +72,19 @@ mod c_fixture_tests { #[test] fn main_argv_vuln_is_confirmed() { - if !cc_available() { - eprintln!("SKIP: cc not available"); - return; - } - let r = run( + let Some(r) = run( "main_argv", "vuln.c", "nyx_entry_main", Cap::CODE_EXEC, 23, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { return; }; assert_confirmed("main_argv", &r); } #[test] fn main_argv_benign_not_confirmed() { - if !cc_available() { - eprintln!("SKIP: cc not available"); - return; - } - let r = run( + let Some(r) = run( "main_argv", "benign.c", "nyx_entry_main", Cap::CODE_EXEC, 11, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { return; }; assert_not_confirmed("main_argv", &r); } @@ -103,27 +92,19 @@ mod c_fixture_tests { #[test] fn libfuzzer_vuln_is_confirmed() { - if !cc_available() { - eprintln!("SKIP: cc not available"); - return; - } - let r = run( + let Some(r) = run( "libfuzzer", "vuln.c", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 16, EntryKind::LibraryApi, PayloadSlot::Param(0), - ); + ) else { return; }; assert_confirmed("libfuzzer", &r); } #[test] fn libfuzzer_benign_not_confirmed() { - if !cc_available() { - eprintln!("SKIP: cc not available"); - return; - } - let r = run( + let Some(r) = run( "libfuzzer", "benign.c", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 10, EntryKind::LibraryApi, PayloadSlot::Param(0), - ); + ) else { return; }; assert_not_confirmed("libfuzzer", &r); } @@ -131,27 +112,19 @@ mod c_fixture_tests { #[test] fn free_fn_vuln_is_confirmed() { - if !cc_available() { - eprintln!("SKIP: cc not available"); - return; - } - let r = run( + let Some(r) = run( "free_fn", "vuln.c", "run", Cap::CODE_EXEC, 15, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { return; }; assert_confirmed("free_fn", &r); } #[test] fn free_fn_benign_not_confirmed() { - if !cc_available() { - eprintln!("SKIP: cc not available"); - return; - } - let r = run( + let Some(r) = run( "free_fn", "benign.c", "run", Cap::CODE_EXEC, 10, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { return; }; assert_not_confirmed("free_fn", &r); } } diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index 06fc9031..7eaddeb4 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -74,6 +74,16 @@ pub enum Prerequisite { /// the resolution path skips with a structured reason instead of /// failing the test. NodeModuleAvailable(&'static str), + /// A binary must resolve on `PATH` and respond to `--version` with + /// exit code 0, but the binary name can be overridden via an env + /// var. Used by the C / C++ fixture suites where `cc` / `c++` can + /// be swapped in for `clang` / `gcc` via `NYX_CC_BIN` / `NYX_CXX_BIN`. + /// The env var's *value* (when set) names the binary to probe; + /// otherwise `default` is used. + CommandAvailableEnvOverride { + env_var: &'static str, + default: &'static str, + }, } /// Phase 29 (Track I): why the harness skipped a fixture. Carried by @@ -120,6 +130,27 @@ pub fn check_prerequisites(reqs: &[Prerequisite]) -> Result<(), SkipReason> { return Err(SkipReason::MissingCommand(cmd)); } } + Prerequisite::CommandAvailableEnvOverride { env_var, default } => { + // Resolve binary name from the env var when set; fall + // back to `default` so an unset override stays + // transparent to the existing acceptance contract. The + // suite under test reads the SAME env var to pick the + // binary it will execute, so the prereq probe lines up + // with the actual invocation. + let env_value = std::env::var(env_var).ok(); + let bin: &str = match env_value.as_deref() { + Some(v) if !v.is_empty() => v, + _ => default, + }; + let ok = std::process::Command::new(bin) + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + if !ok { + return Err(SkipReason::MissingCommand(default)); + } + } Prerequisite::EnvVar(var) => { if std::env::var(var).is_err() { return Err(SkipReason::MissingEnvVar(var)); diff --git a/tests/cpp_fixtures.rs b/tests/cpp_fixtures.rs index 401f0e3f..ee430863 100644 --- a/tests/cpp_fixtures.rs +++ b/tests/cpp_fixtures.rs @@ -15,20 +15,16 @@ mod common; #[cfg(feature = "dynamic")] mod cpp_fixture_tests { - use crate::common::fixture_harness::run_shape_fixture_lang; + use crate::common::fixture_harness::{run_shape_fixture_lang_or_skip, Prerequisite}; use nyx_scanner::dynamic::spec::PayloadSlot; use nyx_scanner::evidence::{EntryKind, VerifyResult, VerifyStatus}; use nyx_scanner::labels::Cap; use nyx_scanner::symbol::Lang; - fn cxx_available() -> bool { - let bin = std::env::var("NYX_CXX_BIN").unwrap_or_else(|_| "c++".to_owned()); - std::process::Command::new(&bin) - .arg("--version") - .output() - .map(|o| o.status.success()) - .unwrap_or(false) - } + const CXX_REQ: &[Prerequisite] = &[Prerequisite::CommandAvailableEnvOverride { + env_var: "NYX_CXX_BIN", + default: "c++", + }]; fn assert_confirmed(shape: &str, result: &VerifyResult) { assert_eq!( @@ -57,6 +53,7 @@ mod cpp_fixture_tests { ); } + #[allow(clippy::too_many_arguments)] fn run( shape: &str, file: &str, @@ -65,9 +62,9 @@ mod cpp_fixture_tests { sink_line: u32, kind: EntryKind, slot: PayloadSlot, - ) -> VerifyResult { - run_shape_fixture_lang( - Lang::Cpp, "cpp", shape, file, func, cap, sink_line, kind, slot, + ) -> Option { + run_shape_fixture_lang_or_skip( + CXX_REQ, Lang::Cpp, "cpp", shape, file, func, cap, sink_line, kind, slot, ) } @@ -75,27 +72,19 @@ mod cpp_fixture_tests { #[test] fn main_argv_vuln_is_confirmed() { - if !cxx_available() { - eprintln!("SKIP: c++ not available"); - return; - } - let r = run( + let Some(r) = run( "main_argv", "vuln.cpp", "nyx_entry_main", Cap::CODE_EXEC, 16, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { return; }; assert_confirmed("main_argv", &r); } #[test] fn main_argv_benign_not_confirmed() { - if !cxx_available() { - eprintln!("SKIP: c++ not available"); - return; - } - let r = run( + let Some(r) = run( "main_argv", "benign.cpp", "nyx_entry_main", Cap::CODE_EXEC, 11, EntryKind::CliSubcommand, PayloadSlot::Argv(0), - ); + ) else { return; }; assert_not_confirmed("main_argv", &r); } @@ -103,27 +92,19 @@ mod cpp_fixture_tests { #[test] fn libfuzzer_vuln_is_confirmed() { - if !cxx_available() { - eprintln!("SKIP: c++ not available"); - return; - } - let r = run( + let Some(r) = run( "libfuzzer", "vuln.cpp", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 15, EntryKind::LibraryApi, PayloadSlot::Param(0), - ); + ) else { return; }; assert_confirmed("libfuzzer", &r); } #[test] fn libfuzzer_benign_not_confirmed() { - if !cxx_available() { - eprintln!("SKIP: c++ not available"); - return; - } - let r = run( + let Some(r) = run( "libfuzzer", "benign.cpp", "LLVMFuzzerTestOneInput", Cap::CODE_EXEC, 10, EntryKind::LibraryApi, PayloadSlot::Param(0), - ); + ) else { return; }; assert_not_confirmed("libfuzzer", &r); } @@ -131,27 +112,19 @@ mod cpp_fixture_tests { #[test] fn free_fn_vuln_is_confirmed() { - if !cxx_available() { - eprintln!("SKIP: c++ not available"); - return; - } - let r = run( + let Some(r) = run( "free_fn", "vuln.cpp", "run", Cap::CODE_EXEC, 12, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { return; }; assert_confirmed("free_fn", &r); } #[test] fn free_fn_benign_not_confirmed() { - if !cxx_available() { - eprintln!("SKIP: c++ not available"); - return; - } - let r = run( + let Some(r) = run( "free_fn", "benign.cpp", "run", Cap::CODE_EXEC, 10, EntryKind::Function, PayloadSlot::Param(0), - ); + ) else { return; }; assert_not_confirmed("free_fn", &r); } } diff --git a/tests/dynamic_fixtures/stubs_e2e/rust/http/vuln/main.rs b/tests/dynamic_fixtures/stubs_e2e/rust/http/vuln/main.rs new file mode 100644 index 00000000..97a1cf42 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/rust/http/vuln/main.rs @@ -0,0 +1,18 @@ +// Phase 10 (Track D.3) — Rust HTTP recorder body-only fragment. +// +// Wrapped at test time by `wrap_rust_fragment(body, shim)` in +// `tests/stubs_e2e_per_lang.rs`: the wrapper prepends the Rust probe +// shim (which carries `__nyx_stub_http_record`) and a one-line +// `Cargo.toml` so `cargo run --quiet` builds the program in place. +// +// The fragment never issues the actual network call. It records the +// SSRF attempt at 169.254.169.254/latest/meta-data/ through the shim +// recorder so the host-side HttpStub captures the boundary event. +let _endpoint = std::env::var("NYX_HTTP_ENDPOINT").unwrap_or_default(); +let detail: &[(&str, &str)] = &[("driver", "manual")]; +__nyx_stub_http_record( + "GET", + "http://169.254.169.254/latest/meta-data/", + None, + detail, +); diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index d4b31aa1..182c0b95 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -26,6 +26,7 @@ use nyx_scanner::dynamic::lang::javascript::probe_shim as node_probe_shim; use nyx_scanner::dynamic::lang::php::probe_shim as php_probe_shim; use nyx_scanner::dynamic::lang::python::probe_shim as python_probe_shim; use nyx_scanner::dynamic::lang::ruby::probe_shim as ruby_probe_shim; +use nyx_scanner::dynamic::lang::rust::probe_shim as rust_probe_shim; use nyx_scanner::dynamic::stubs::{HttpStub, SqlStub, StubProvider}; use std::path::PathBuf; use std::process::Command; @@ -71,6 +72,14 @@ fn ruby_available() -> bool { .unwrap_or(false) } +fn cargo_available() -> bool { + Command::new("cargo") + .arg("--version") + .output() + .map(|o| o.status.success()) + .unwrap_or(false) +} + fn java_available() -> bool { // The Java shim helpers use `java MainSource.java` single-file // source-mode (JEP 330, JDK 11+) so only the `java` runtime is @@ -127,6 +136,39 @@ fn wrap_go_fragment(body: &str, shim: &str) -> String { ) } +/// Wrap the body-only Rust HTTP fragment in a complete crate: prepend +/// the Rust probe shim (which carries `__nyx_stub_http_record`) at +/// file scope and wrap the fragment as the body of `fn main()`. The +/// caller writes the result alongside a one-line `Cargo.toml` that +/// pins `libc = "0.2"` (the shim's `__nyx_install_crash_guard` path +/// references `libc::sigaction`) and drives the build through +/// `cargo run --quiet`. Mirrors the production Rust emitter ordering +/// — shim at file scope, then `fn main()` calling into it. +fn wrap_rust_fragment(body: &str, shim: &str) -> String { + format!( + "{shim}\n\ + fn main() {{\n\ + {body}\n\ + }}\n" + ) +} + +/// One-line Cargo.toml for the Rust stub-recorder driver. Mirrors +/// the Phase 26 chain_step manifest (session 0014) — `[[bin]]` points +/// at `main.rs` so `cargo run --quiet` builds the source the test +/// just wrote, and `libc = "0.2"` is unconditionally pinned because +/// the spliced probe shim's `__nyx_install_crash_guard` references +/// `libc::sigaction` on Unix. +const RUST_STUB_CARGO_TOML: &str = "[package]\n\ + name = \"nyx-stub-driver\"\n\ + version = \"0.0.1\"\n\ + edition = \"2021\"\n\n\ + [[bin]]\n\ + name = \"stub_driver\"\n\ + path = \"main.rs\"\n\n\ + [dependencies]\n\ + libc = \"0.2\"\n"; + fn fixture_path(rel: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) .join("tests") @@ -1086,3 +1128,132 @@ fn node_sql_shim_recorder_is_noop_without_log_env() { events.len() ); } + +/// Returns a shared CARGO_TARGET_DIR for Rust stub-recorder tests so +/// repeated runs reuse the libc build artifacts instead of paying +/// the full compile cost per test. Lives under the host crate's +/// own `target/` so `cargo clean` still wipes it. +fn rust_stub_target_dir() -> PathBuf { + PathBuf::from(env!("CARGO_TARGET_TMPDIR")).join("stubs_e2e_rust") +} + +#[test] +fn rust_http_stub_captures_attempted_outbound_via_shim_recorder() { + // Phase 10 (Track D.3) HTTP recording: Rust leg of the side-channel + // `__nyx_stub_http_record` helper. Mirrors the Python / Node / PHP / + // Go / Ruby / Java HTTP tests — records an SSRF attempt without + // issuing the actual network call. Uses the `extra_files`-driven + // `Cargo.toml` shape session 0014 prototyped for chain steps: write + // a one-line manifest alongside the wrapped fragment so `cargo run + // --quiet` resolves `libc` (referenced by the spliced probe shim's + // `__nyx_install_crash_guard`) without any host crate-cache assumptions. + if !cargo_available() { + eprintln!("SKIP: cargo not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("HttpStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("rust/http/vuln/main.rs")) + .expect("read rust fragment"); + let source = wrap_rust_fragment(&fragment, rust_probe_shim()); + + let crate_dir = workdir.path().join("driver"); + std::fs::create_dir_all(&crate_dir).expect("create crate dir"); + std::fs::write(crate_dir.join("Cargo.toml"), RUST_STUB_CARGO_TOML) + .expect("write Cargo.toml"); + std::fs::write(crate_dir.join("main.rs"), source).expect("write main.rs"); + + let output = Command::new("cargo") + .arg("run") + .arg("--quiet") + .arg("--manifest-path") + .arg(crate_dir.join("Cargo.toml")) + .env("CARGO_TARGET_DIR", rust_stub_target_dir()) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("cargo run rust driver"); + assert!( + output.status.success(), + "driver must exit 0; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "HttpStub must capture at least one event after the Rust shim recorder fires" + ); + let hit = events + .iter() + .find(|e| e.summary.contains("169.254.169.254")) + .expect("recorded URL must contain the SSRF marker"); + assert_eq!( + hit.detail.get("method").map(String::as_str), + Some("GET"), + "method detail must surface on the recorded event" + ); + assert_eq!( + hit.detail.get("url").map(String::as_str), + Some("http://169.254.169.254/latest/meta-data/"), + ); + assert_eq!( + hit.detail.get("driver").map(String::as_str), + Some("manual"), + "detail slice passed to __nyx_stub_http_record must surface as event detail entries" + ); +} + +#[test] +fn rust_http_shim_recorder_is_noop_without_log_env() { + if !cargo_available() { + eprintln!("SKIP: cargo not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = HttpStub::start(workdir.path()).expect("HttpStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("rust/http/vuln/main.rs")) + .expect("read rust fragment"); + let source = wrap_rust_fragment(&fragment, rust_probe_shim()); + + let crate_dir = workdir.path().join("driver_no_log"); + std::fs::create_dir_all(&crate_dir).expect("create crate dir"); + std::fs::write(crate_dir.join("Cargo.toml"), RUST_STUB_CARGO_TOML) + .expect("write Cargo.toml"); + std::fs::write(crate_dir.join("main.rs"), source).expect("write main.rs"); + + let output = Command::new("cargo") + .arg("run") + .arg("--quiet") + .arg("--manifest-path") + .arg(crate_dir.join("Cargo.toml")) + .env("CARGO_TARGET_DIR", rust_stub_target_dir()) + .env("NYX_HTTP_ENDPOINT", &endpoint) + .env_remove("NYX_HTTP_LOG") + .output() + .expect("cargo run rust driver"); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_HTTP_LOG; stdout = {}\nstderr = {}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + events.is_empty(), + "no events expected when the recording env var is unset, got {} entries", + events.len() + ); +}