From aa209148b0e6e2e254316b518e37a2cf55610532 Mon Sep 17 00:00:00 2001 From: pitboss Date: Sat, 16 May 2026 11:27:45 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0020 (20260516T052512Z-20f8) --- src/dynamic/lang/go.rs | 45 ++ src/dynamic/lang/java.rs | 46 ++ src/dynamic/lang/ruby.rs | 40 ++ .../stubs_e2e/go/sql/vuln/main.go | 29 ++ .../java/sql/vuln/main.java.fragment | 26 ++ .../stubs_e2e/ruby/sql/vuln/main.rb | 21 + .../stubs_e2e/rust/sql/vuln/main.rs | 18 + tests/stubs_e2e_per_lang.rs | 437 +++++++++++++++++- 8 files changed, 649 insertions(+), 13 deletions(-) create mode 100644 tests/dynamic_fixtures/stubs_e2e/go/sql/vuln/main.go create mode 100644 tests/dynamic_fixtures/stubs_e2e/java/sql/vuln/main.java.fragment create mode 100644 tests/dynamic_fixtures/stubs_e2e/ruby/sql/vuln/main.rb create mode 100644 tests/dynamic_fixtures/stubs_e2e/rust/sql/vuln/main.rs diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 6e0d1800..933a97c7 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -433,6 +433,34 @@ func __nyx_stub_http_record(method, url, body string, detail map[string]string) } f.WriteString(method + " " + url + "\n") } + +// Phase 10 (Track D.3) SQL recording helper. When the verifier spawned a +// SqlStub it publishes the side-channel log path through NYX_SQL_LOG; a +// sink callsite whose query never reaches the on-the-wire SQLite engine +// (no database/sql driver imported, query pre-flighted before sql.Open, +// network-isolated sandbox) can call this helper to surface the attempted +// query. Hash-prefixed detail lines followed by the query line so +// SqlStub::drain_events parses every language stream identically. No-op +// when NYX_SQL_LOG is unset so the same harness still runs cleanly under +// modes that did not spawn a stub. +func __nyx_stub_sql_record(query string, detail map[string]string) { + p := os.Getenv("NYX_SQL_LOG") + if p == "" { + return + } + f, err := os.OpenFile(p, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + return + } + defer f.Close() + for k, v := range detail { + f.WriteString("# " + k + ": " + v + "\n") + } + f.WriteString(query) + if !strings.HasSuffix(query, "\n") { + f.WriteString("\n") + } +} "## } @@ -921,6 +949,23 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_sql_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("func __nyx_stub_sql_record"), + "Go probe shim must define __nyx_stub_sql_record" + ); + assert!( + shim.contains("NYX_SQL_LOG"), + "stub recorder must read NYX_SQL_LOG" + ); + assert!( + shim.contains("strings.HasSuffix(query, \"\\n\")"), + "Go SQL recorder must guarantee a trailing newline on the query line so SqlStub::drain_events frames each record" + ); + } + #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { let step = chain_step(Some(b"")); diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 97e8e069..41c34d2f 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -404,6 +404,35 @@ pub fn probe_shim() -> &'static str { // best-effort } } + + // Phase 10 (Track D.3) SQL recording helper. When the verifier spawned a + // SqlStub it publishes the side-channel log path through NYX_SQL_LOG; a + // sink call site whose query never reaches the on-the-wire SQLite engine + // (e.g. classpath lacks sqlite-jdbc, or the harness pre-flights the SQL + // string before opening the connection) can call this helper to surface + // the attempted query. Hash-prefixed detail lines followed by the query + // line so SqlStub::drain_events parses every language stream identically. + // Same hash-via-String.valueOf trick as __nyx_stub_http_record so this + // method body contains no literal `"#` sequence that would terminate the + // surrounding Rust raw string. + static void __nyx_stub_sql_record(String query, java.util.Map detail) { + String p = System.getenv("NYX_SQL_LOG"); + if (p == null || p.isEmpty()) return; + String hashSp = String.valueOf('#') + " "; + try (java.io.FileWriter fw = new java.io.FileWriter(p, true)) { + if (detail != null) { + for (java.util.Map.Entry e : detail.entrySet()) { + fw.write(hashSp + e.getKey() + ": " + e.getValue() + "\n"); + } + } + fw.write(query); + if (!query.endsWith("\n")) { + fw.write("\n"); + } + } catch (java.io.IOException e) { + // best-effort + } + } "## } @@ -1094,6 +1123,23 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_sql_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("static void __nyx_stub_sql_record"), + "Java probe shim must define __nyx_stub_sql_record" + ); + assert!( + shim.contains("\"NYX_SQL_LOG\""), + "Java SQL recorder must read NYX_SQL_LOG to find the side-channel log" + ); + assert!( + shim.contains("query.endsWith(\"\\n\")"), + "Java SQL recorder must guarantee a trailing newline on the query line so SqlStub::drain_events frames each record" + ); + } + #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { let step = chain_step(Some(b"")); diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 8e2ee106..531c083a 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -305,6 +305,29 @@ def __nyx_stub_http_record(method, url, body = nil, **detail) rescue StandardError end end + +# Phase 10 (Track D.3) SQL recording helper. When the verifier spawned a +# SqlStub it publishes the side-channel log path through NYX_SQL_LOG; a +# sink call site whose query never reaches the on-the-wire SQLite engine +# (no sqlite3 gem on the host, query pre-flighted before +# SQLite3::Database.open) can call this helper to surface the attempted +# query. Hash-prefixed detail lines followed by the query line so +# SqlStub::drain_events parses every language stream identically. No-op +# when NYX_SQL_LOG is unset. Single-quoted Ruby string literals keep this +# helper free of the literal hash-after-double-quote sequence. +def __nyx_stub_sql_record(query, **detail) + p = ENV['NYX_SQL_LOG'] + return if p.nil? || p.empty? + begin + File.open(p, 'a') do |f| + detail.each { |k, v| f.puts('# ' + k.to_s + ': ' + v.to_s) } + line = query.to_s + line += "\n" unless line.end_with?("\n") + f.write(line) + end + rescue StandardError + end +end "# } @@ -825,6 +848,23 @@ mod tests { ); } + #[test] + fn probe_shim_publishes_stub_sql_recorder() { + let shim = probe_shim(); + assert!( + shim.contains("def __nyx_stub_sql_record"), + "Ruby probe shim must define __nyx_stub_sql_record" + ); + assert!( + shim.contains("ENV['NYX_SQL_LOG']"), + "Ruby SQL recorder must read NYX_SQL_LOG to find the side-channel log" + ); + assert!( + shim.contains("line.end_with?"), + "Ruby SQL recorder must guarantee a trailing newline on the query line so SqlStub::drain_events frames each record" + ); + } + #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { let step = chain_step(Some(b"")); diff --git a/tests/dynamic_fixtures/stubs_e2e/go/sql/vuln/main.go b/tests/dynamic_fixtures/stubs_e2e/go/sql/vuln/main.go new file mode 100644 index 00000000..890c4045 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/go/sql/vuln/main.go @@ -0,0 +1,29 @@ +// Phase 10 (Track D.3) stub-end-to-end fixture: Go + SQL. +// +// Body-only fragment, not a standalone `go run`-able program. The +// companion test in `tests/stubs_e2e_per_lang.rs` wraps these lines +// in `package main` + the union of stdlib imports required by both +// the spliced probe shim and this fragment, places the Go probe +// shim ahead of `func main`, and then invokes `go run` on the +// resulting file. +// +// The verifier publishes: +// +// NYX_SQL_ENDPOINT — absolute path of a SQLite DB the SqlStub owns. +// NYX_SQL_LOG — companion log path the harness appends executed +// queries to so the host SqlStub picks them up on +// drain_events() even when the harness never opens +// an on-the-wire driver (no go-sqlite3 / pgx / +// mysql dep on the dynamic CI matrix; query +// pre-flighted before sql.Open). +// +// This fragment records the tautology query through the Go shim +// helper __nyx_stub_sql_record as `driver = "manual"` so the test +// stays stdlib-only — no `database/sql` import, no go.mod driver +// dep, no libsqlite3-dev system package. Mirrors the Phase 26 +// "no live driver available" path that real Go sink callsites take +// when the build matrix lacks a driver. +query := "SELECT 1 WHERE 'a' = 'a' OR 1=1 --" +__nyx_stub_sql_record(query, map[string]string{"driver": "manual"}) +// Echo so the host can confirm the driver ran end-to-end. +fmt.Print(os.Getenv("NYX_SQL_ENDPOINT")) diff --git a/tests/dynamic_fixtures/stubs_e2e/java/sql/vuln/main.java.fragment b/tests/dynamic_fixtures/stubs_e2e/java/sql/vuln/main.java.fragment new file mode 100644 index 00000000..37173da0 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/java/sql/vuln/main.java.fragment @@ -0,0 +1,26 @@ +// Phase 10 (Track D.3) stub-end-to-end fixture: Java + SQL. +// +// The verifier publishes: +// +// * NYX_SQL_ENDPOINT — absolute path of a SQLite DB the SqlStub owns. +// * NYX_SQL_LOG — companion log path the harness appends executed +// queries to so the host SqlStub picks them up on drain_events() +// even when the harness never opens an on-the-wire JDBC connection +// (classpath lacks sqlite-jdbc, SQL string is pre-flighted before +// DriverManager.getConnection, sandbox blocks file-DB access). +// +// This file is a body-only fragment: the companion test in +// tests/stubs_e2e_per_lang.rs wraps it with a `public class Main { … }` +// shell that splices the Java probe shim as class members ahead of +// `public static void main`, so the shim's __nyx_stub_sql_record helper +// is in scope. The fixture stays JDK-stdlib only — no java.sql import, +// no sqlite-jdbc jar on the classpath — by recording the attempted +// tautology with `driver = "manual"`. This mirrors the Phase 26 +// "no live driver available" path that real Java sink callsites take +// when the build matrix lacks a JDBC driver. +String query = "SELECT 1 WHERE 'a' = 'a' OR 1=1 --"; +java.util.Map detail = new java.util.LinkedHashMap<>(); +detail.put("driver", "manual"); +__nyx_stub_sql_record(query, detail); +String ep = System.getenv("NYX_SQL_ENDPOINT"); +System.out.println(ep == null ? "no-endpoint" : ep); diff --git a/tests/dynamic_fixtures/stubs_e2e/ruby/sql/vuln/main.rb b/tests/dynamic_fixtures/stubs_e2e/ruby/sql/vuln/main.rb new file mode 100644 index 00000000..ebe3ba5b --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/ruby/sql/vuln/main.rb @@ -0,0 +1,21 @@ +# Phase 10 (Track D.3) stub-end-to-end fixture: Ruby + SQL. +# +# The verifier publishes: +# +# * NYX_SQL_ENDPOINT — absolute path of a SQLite DB the SqlStub owns. +# * NYX_SQL_LOG — companion log path the harness appends executed +# queries to so the host SqlStub picks them up on drain_events() +# even when the harness never opens an on-the-wire driver (sqlite3 +# gem absent on minimal CI images, query pre-flighted before +# SQLite3::Database.open). +# +# This fixture stays gem-free by recording the tautology through +# __nyx_stub_sql_record as driver = 'manual'. No sqlite3 require, no +# Gemfile dep, no Prerequisite::GemAvailable variant required. Mirrors +# the Phase 26 "no live driver available" path that real Ruby sink +# callsites take when the build matrix lacks a driver. + +query = "SELECT 1 WHERE 'a' = 'a' OR 1=1 --" +__nyx_stub_sql_record(query, driver: 'manual') +# Echo so the host can confirm the driver ran end-to-end. +$stdout.puts(ENV['NYX_SQL_ENDPOINT'] || 'no-endpoint') diff --git a/tests/dynamic_fixtures/stubs_e2e/rust/sql/vuln/main.rs b/tests/dynamic_fixtures/stubs_e2e/rust/sql/vuln/main.rs new file mode 100644 index 00000000..f0bba534 --- /dev/null +++ b/tests/dynamic_fixtures/stubs_e2e/rust/sql/vuln/main.rs @@ -0,0 +1,18 @@ +// Phase 10 (Track D.3) — Rust SQL 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_sql_record`) and a one-line +// `Cargo.toml` so `cargo run --quiet` builds the program in place. +// +// Rust has no stdlib SQLite client (rusqlite is a heavyweight C-link +// dep that would force a libsqlite3-dev prereq on the dynamic CI +// matrix). The fixture surfaces the attempted tautology query +// through the shim recorder so the host-side SqlStub captures it as +// `driver = "manual"`, mirroring the Phase 26 "no live driver +// available" path that real Rust sink callsites take when the build +// matrix lacks a DB driver. +let _endpoint = std::env::var("NYX_SQL_ENDPOINT").unwrap_or_default(); +let query = "SELECT 1 WHERE 'a' = 'a' OR 1=1 --"; +let detail: &[(&str, &str)] = &[("driver", "manual")]; +__nyx_stub_sql_record(query, detail); diff --git a/tests/stubs_e2e_per_lang.rs b/tests/stubs_e2e_per_lang.rs index 182c0b95..052b5e83 100644 --- a/tests/stubs_e2e_per_lang.rs +++ b/tests/stubs_e2e_per_lang.rs @@ -153,21 +153,29 @@ fn wrap_rust_fragment(body: &str, shim: &str) -> String { ) } -/// One-line Cargo.toml for the Rust stub-recorder driver. Mirrors +/// Per-fixture 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"; +/// `libc::sigaction` on Unix. Caller supplies a unique `slug` per +/// test so the package + binary names do not collide in the shared +/// `CARGO_TARGET_DIR` when nextest runs the Rust stub tests in +/// parallel (every test still benefits from the cached `libc` build, +/// only the final `nyx-stub-driver-` link is per-test). +fn rust_stub_cargo_toml(slug: &str) -> String { + format!( + "[package]\n\ + name = \"nyx-stub-driver-{slug}\"\n\ + version = \"0.0.1\"\n\ + edition = \"2021\"\n\n\ + [[bin]]\n\ + name = \"stub_driver_{slug}\"\n\ + path = \"main.rs\"\n\n\ + [dependencies]\n\ + libc = \"0.2\"\n" + ) +} fn fixture_path(rel: &str) -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")) @@ -876,6 +884,103 @@ fn go_http_shim_recorder_is_noop_without_log_env() { ); } +#[test] +fn go_sql_stub_captures_tautology_query_via_shim_recorder() { + // Phase 10 (Track D.3) SQL recording: Go leg of the side-channel + // `__nyx_stub_sql_record` helper. Mirrors the Python / Node / PHP / + // Rust / Java SQL tests — the Go fragment never opens a live + // `database/sql` handle (no driver imported; pulling go-sqlite3 / + // pgx / mysql would force a go.mod dep onto every dynamic CI matrix + // row) so it surfaces the attempted tautology query through the + // shim recorder as `driver = "manual"`. + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("SqlStub must publish a recording endpoint"); + + let fragment = + std::fs::read_to_string(fixture_path("go/sql/vuln/main.go")).expect("read go fragment"); + let combined = wrap_go_fragment(&fragment, go_probe_shim()); + + let script_path = workdir.path().join("driver_sql.go"); + std::fs::write(&script_path, combined).expect("write go driver"); + + let output = Command::new("go") + .arg("run") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("go driver"); + assert!( + output.status.success(), + "driver must exit 0; stderr = {}", + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "SqlStub must capture at least one event after the Go shim recorder fires" + ); + let tautology = events + .iter() + .find(|e| e.summary.contains("OR 1=1")) + .expect("recorded query must contain the tautology marker"); + assert_eq!( + tautology.detail.get("driver").map(String::as_str), + Some("manual"), + "detail map entries passed to __nyx_stub_sql_record must surface as event detail entries" + ); +} + +#[test] +fn go_sql_shim_recorder_is_noop_without_log_env() { + if !go_available() { + eprintln!("SKIP: go not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let fragment = + std::fs::read_to_string(fixture_path("go/sql/vuln/main.go")).expect("read go fragment"); + let combined = wrap_go_fragment(&fragment, go_probe_shim()); + + let script_path = workdir.path().join("driver_sql_no_log.go"); + std::fs::write(&script_path, combined).expect("write go driver"); + + let output = Command::new("go") + .arg("run") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env_remove("NYX_SQL_LOG") + .output() + .expect("go driver"); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_SQL_LOG; stderr = {}", + 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() + ); +} + #[test] fn ruby_http_stub_captures_attempted_outbound_via_shim_recorder() { // Phase 10 (Track D.3) HTTP recording: Ruby leg of the side-channel @@ -983,6 +1088,105 @@ fn ruby_http_shim_recorder_is_noop_without_log_env() { ); } +#[test] +fn ruby_sql_stub_captures_tautology_query_via_shim_recorder() { + // Phase 10 (Track D.3) SQL recording: Ruby leg of the side-channel + // `__nyx_stub_sql_record` helper. Mirrors the Python / Node / PHP / + // Rust / Java / Go SQL tests — the Ruby fragment never opens a live + // sqlite3 handle (no require, no gem dep) so it surfaces the + // attempted tautology query through the shim recorder as + // `driver = "manual"`. + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("SqlStub must publish a recording endpoint"); + + let fixture = + std::fs::read_to_string(fixture_path("ruby/sql/vuln/main.rb")).expect("read fixture"); + let mut combined = String::with_capacity(ruby_probe_shim().len() + fixture.len() + 64); + combined.push_str(ruby_probe_shim()); + combined.push_str("\n# ── fixture begins ─\n"); + combined.push_str(&fixture); + + let script_path = workdir.path().join("driver_sql.rb"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("ruby") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("ruby driver"); + assert!( + output.status.success(), + "driver must exit 0; stderr = {}", + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "SqlStub must capture at least one event after the Ruby shim recorder fires" + ); + let tautology = events + .iter() + .find(|e| e.summary.contains("OR 1=1")) + .expect("recorded query must contain the tautology marker"); + assert_eq!( + tautology.detail.get("driver").map(String::as_str), + Some("manual"), + "kwargs passed to __nyx_stub_sql_record must surface as event detail entries" + ); +} + +#[test] +fn ruby_sql_shim_recorder_is_noop_without_log_env() { + if !ruby_available() { + eprintln!("SKIP: ruby not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let fixture = + std::fs::read_to_string(fixture_path("ruby/sql/vuln/main.rb")).expect("read fixture"); + let mut combined = String::new(); + combined.push_str(ruby_probe_shim()); + combined.push('\n'); + combined.push_str(&fixture); + let script_path = workdir.path().join("driver_sql_no_log.rb"); + std::fs::write(&script_path, combined).expect("write driver"); + + let output = Command::new("ruby") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env_remove("NYX_SQL_LOG") + .output() + .expect("ruby driver"); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_SQL_LOG; stderr = {}", + 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() + ); +} + #[test] fn java_http_stub_captures_attempted_outbound_via_shim_recorder() { // Phase 10 (Track D.3) HTTP recording: Java leg of the side-channel @@ -1051,6 +1255,100 @@ fn java_http_stub_captures_attempted_outbound_via_shim_recorder() { ); } +#[test] +fn java_sql_stub_captures_tautology_query_via_shim_recorder() { + // Phase 10 (Track D.3) SQL recording: Java leg of the side-channel + // `__nyx_stub_sql_record` helper. Mirrors the Python / Node / PHP / + // Rust SQL tests — the Java fragment never opens a live JDBC handle + // (sqlite-jdbc is not stdlib; pulling it would force a classpath + // prereq onto the dynamic CI matrix) so it surfaces the attempted + // tautology query through the shim recorder as `driver = "manual"`. + if !java_available() { + eprintln!("SKIP: java not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("SqlStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("java/sql/vuln/main.java.fragment")) + .expect("read java sql fragment"); + let combined = wrap_java_fragment(&fragment, java_probe_shim()); + + let script_path = workdir.path().join("Main.java"); + std::fs::write(&script_path, combined).expect("write java driver"); + + let output = Command::new("java") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("java driver"); + assert!( + output.status.success(), + "driver must exit 0; stderr = {}", + String::from_utf8_lossy(&output.stderr) + ); + + let events = stub.drain_events(); + assert!( + !events.is_empty(), + "SqlStub must capture at least one event after the Java shim recorder fires" + ); + let tautology = events + .iter() + .find(|e| e.summary.contains("OR 1=1")) + .expect("recorded query must contain the tautology marker"); + assert_eq!( + tautology.detail.get("driver").map(String::as_str), + Some("manual"), + "detail map entries passed to __nyx_stub_sql_record must surface as event detail entries" + ); +} + +#[test] +fn java_sql_shim_recorder_is_noop_without_log_env() { + if !java_available() { + eprintln!("SKIP: java not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("java/sql/vuln/main.java.fragment")) + .expect("read java sql fragment"); + let combined = wrap_java_fragment(&fragment, java_probe_shim()); + + let script_path = workdir.path().join("Main.java"); + std::fs::write(&script_path, combined).expect("write java driver"); + + let output = Command::new("java") + .arg(&script_path) + .env("NYX_SQL_ENDPOINT", &endpoint) + .env_remove("NYX_SQL_LOG") + .output() + .expect("java driver"); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_SQL_LOG; stderr = {}", + 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() + ); +} + #[test] fn java_http_shim_recorder_is_noop_without_log_env() { if !java_available() { @@ -1166,7 +1464,7 @@ fn rust_http_stub_captures_attempted_outbound_via_shim_recorder() { 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) + std::fs::write(crate_dir.join("Cargo.toml"), rust_stub_cargo_toml("http")) .expect("write Cargo.toml"); std::fs::write(crate_dir.join("main.rs"), source).expect("write main.rs"); @@ -1229,7 +1527,7 @@ fn rust_http_shim_recorder_is_noop_without_log_env() { 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) + std::fs::write(crate_dir.join("Cargo.toml"), rust_stub_cargo_toml("http_no_log")) .expect("write Cargo.toml"); std::fs::write(crate_dir.join("main.rs"), source).expect("write main.rs"); @@ -1257,3 +1555,116 @@ fn rust_http_shim_recorder_is_noop_without_log_env() { events.len() ); } + +#[test] +fn rust_sql_stub_captures_tautology_query_via_shim_recorder() { + // Phase 10 (Track D.3) SQL recording: Rust leg of the side-channel + // `__nyx_stub_sql_record` helper. Mirrors the Python / Node / PHP + // SQL tests — the Rust fragment never opens a live SQLite handle + // (no stdlib driver; rusqlite would force libsqlite3-dev onto the + // CI matrix) so it surfaces the attempted tautology query through + // the shim recorder as `driver = "manual"`. Uses the same + // `extra_files`-driven `Cargo.toml` shape as the HTTP siblings so + // `cargo run --quiet` resolves `libc` (referenced by the spliced + // probe shim's `__nyx_install_crash_guard`). + if !cargo_available() { + eprintln!("SKIP: cargo not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let recording = stub + .recording_endpoint() + .expect("SqlStub must publish a recording endpoint"); + + let fragment = std::fs::read_to_string(fixture_path("rust/sql/vuln/main.rs")) + .expect("read rust sql fragment"); + let source = wrap_rust_fragment(&fragment, rust_probe_shim()); + + let crate_dir = workdir.path().join("driver_sql"); + std::fs::create_dir_all(&crate_dir).expect("create crate dir"); + std::fs::write(crate_dir.join("Cargo.toml"), rust_stub_cargo_toml("sql")) + .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_SQL_ENDPOINT", &endpoint) + .env(recording.0, &recording.1) + .output() + .expect("cargo run rust sql 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(), + "SqlStub must capture at least one event after the Rust shim recorder fires" + ); + let tautology = events + .iter() + .find(|e| e.summary.contains("OR 1=1")) + .expect("recorded query must contain the tautology marker"); + assert_eq!( + tautology.detail.get("driver").map(String::as_str), + Some("manual"), + "detail slice passed to __nyx_stub_sql_record must surface as event detail entries" + ); +} + +#[test] +fn rust_sql_shim_recorder_is_noop_without_log_env() { + if !cargo_available() { + eprintln!("SKIP: cargo not available"); + return; + } + + let workdir = TempDir::new().expect("tempdir"); + let stub = SqlStub::start(workdir.path()).expect("SqlStub::start"); + + let endpoint = stub.endpoint(); + let fragment = std::fs::read_to_string(fixture_path("rust/sql/vuln/main.rs")) + .expect("read rust sql fragment"); + let source = wrap_rust_fragment(&fragment, rust_probe_shim()); + + let crate_dir = workdir.path().join("driver_sql_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("sql_no_log")) + .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_SQL_ENDPOINT", &endpoint) + .env_remove("NYX_SQL_LOG") + .output() + .expect("cargo run rust sql driver"); + assert!( + output.status.success(), + "driver must exit 0 even without NYX_SQL_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() + ); +}