diff --git a/src/chain/reverify.rs b/src/chain/reverify.rs index 692cb60b..b774230b 100644 --- a/src/chain/reverify.rs +++ b/src/chain/reverify.rs @@ -53,7 +53,7 @@ use crate::chain::finding::{ChainFinding, ChainSeverity}; use crate::commands::scan::Diag; use crate::dynamic::build_sandbox::dispatch_prepare; use crate::dynamic::harness::{self, BuiltHarness}; -use crate::dynamic::lang; +use crate::dynamic::lang::{self, ChainStepTerminal}; use crate::dynamic::sandbox; use crate::dynamic::spec::HarnessSpec; use crate::dynamic::verify::VerifyOptions; @@ -278,12 +278,18 @@ impl CompositeReverifier for DefaultCompositeReverifier { // Sub-task (c) of the Phase 26 live-execution split: // sequentially run each built chain-step harness through // `sandbox::run`, threading the previous step's stdout into - // the next step via `NYX_PREV_OUTPUT`. The final step's - // `sink_hit` is captured for the detail field; today it stays - // false because `compose_chain_step` does not yet rewrite the - // chain's terminal sink. + // the next step via `NYX_PREV_OUTPUT`. The final step is + // composed with a `ChainStepTerminal` carrying the chain's + // sink callee, so the per-language emitter splices in a + // `__nyx_probe(callee, prev)` call plus the + // `SINK_HIT_SENTINEL` banner that `sandbox::run` detects via + // `SandboxOutcome::sink_hit`. + let terminal = ChainStepTerminal { + sink_callee: chain.sink.function_name.clone(), + sink_cap_bits: chain.sink.cap_bits, + }; let (steps_run, sandbox_errors, steps_timeout, nonzero_exits, final_sink_hit) = - run_chain_steps(&built_steps, &opts.sandbox); + run_chain_steps(&built_steps, &opts.sandbox, &terminal); let detail = format!( "composite chain re-verification: live runs collect step coverage; \ @@ -291,22 +297,49 @@ impl CompositeReverifier for DefaultCompositeReverifier { built {built}/{derived} (cache_hit={cache_hits}, build_ms={total_build_ms}, build_errors={build_errors}); \ ran {steps_run}/{built} (sandbox_errors={sandbox_errors}, timeouts={steps_timeout}, nonzero_exits={nonzero_exits}, final_sink_hit={final_sink_hit})" ); - VerifyResult { - finding_id, - status: VerifyStatus::Inconclusive, - triggered_payload: None, - reason: None, - inconclusive_reason: Some(InconclusiveReason::BackendInsufficient { - backend: "composite-chain".to_owned(), - oracle_kind: "chain-step-harness".to_owned(), - }), - detail: Some(detail), - attempts: vec![], - toolchain_match: None, - differential: None, - replay_stable: None, - wrong: None, - hardening_outcome: None, + + // Verdict resolution: a composite chain is `Confirmed` when + // (a) every derived step built, (b) every built step ran + // without a sandbox error, (c) the final step's terminal + // compose fired the sink sentinel (`final_sink_hit=true`). + // Anything short of all three keeps the verdict + // `Inconclusive(BackendInsufficient)` so the chain's severity + // takes the existing downgrade rule. + let all_built = derived > 0 && built == derived; + let all_ran = built > 0 && steps_run == built && sandbox_errors == 0; + if all_built && all_ran && final_sink_hit { + VerifyResult { + finding_id, + status: VerifyStatus::Confirmed, + triggered_payload: None, + reason: None, + inconclusive_reason: None, + detail: Some(detail), + attempts: vec![], + toolchain_match: None, + differential: None, + replay_stable: None, + wrong: None, + hardening_outcome: None, + } + } else { + VerifyResult { + finding_id, + status: VerifyStatus::Inconclusive, + triggered_payload: None, + reason: None, + inconclusive_reason: Some(InconclusiveReason::BackendInsufficient { + backend: "composite-chain".to_owned(), + oracle_kind: "chain-step-harness".to_owned(), + }), + detail: Some(detail), + attempts: vec![], + toolchain_match: None, + differential: None, + replay_stable: None, + wrong: None, + hardening_outcome: None, + } } } } @@ -337,6 +370,7 @@ impl CompositeReverifier for DefaultCompositeReverifier { fn run_chain_steps( built_steps: &[(PathBuf, &HarnessSpec)], base_opts: &sandbox::SandboxOptions, + terminal: &ChainStepTerminal, ) -> (usize, usize, usize, usize, bool) { let mut steps_run = 0usize; let mut sandbox_errors = 0usize; @@ -346,7 +380,8 @@ fn run_chain_steps( let mut prev_output: Option> = None; let last_idx = built_steps.len().saturating_sub(1); for (idx, (workdir, spec)) in built_steps.iter().enumerate() { - let step = lang::compose_chain_step(spec.lang, prev_output.as_deref()); + let step_terminal = if idx == last_idx { Some(terminal) } else { None }; + let step = lang::compose_chain_step(spec.lang, prev_output.as_deref(), step_terminal); let step_path = workdir.join(&step.filename); if let Some(parent) = step_path.parent() { @@ -762,7 +797,11 @@ mod tests { // function of the (steps_run, sandbox_errors, timeouts, // nonzero_exits, final_sink_hit) tuple this helper returns. let opts = sandbox::SandboxOptions::default(); - let result = run_chain_steps(&[], &opts); + let terminal = ChainStepTerminal { + sink_callee: "noop".into(), + sink_cap_bits: 0, + }; + let result = run_chain_steps(&[], &opts, &terminal); assert_eq!(result, (0, 0, 0, 0, false)); } diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index cb3bab74..da12a8e3 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -27,7 +27,7 @@ //! - `PayloadSlot::EnvVar(name)` — set env var before invoking entry. //! - `PayloadSlot::Argv(n)` — `main(argc, argv)` shape: appended to argv. -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -372,28 +372,43 @@ impl LangEmitter for CEmitter { ) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } /// Phase 26 — C chain-step harness. /// /// Splices the C probe shim ([`probe_shim`]) ahead of a minimal driver -/// that reads `NYX_PREV_OUTPUT` and forwards it on stdout. The shim's -/// static functions (`__nyx_probe`, `__nyx_install_crash_guard`, -/// `__nyx_stub_sql_record`, `__nyx_stub_http_record`) become callable -/// from a future sink-rewrite pass without bringing in another -/// translation unit. Unreferenced shim helpers stay quiet under -/// default `cc` flags — `-Wunused-function` is not on the warning -/// baseline so dead helpers do not fail the build. +/// that reads `NYX_PREV_OUTPUT` and forwards it on stdout. When the +/// step is the chain's terminal step (`terminal == Some(_)`) the driver +/// also calls `__nyx_probe(callee, 1, prev)` and emits the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] on stdout so the runner +/// flips `sink_hit` for the chain. /// /// Shell-wraps `cc` + run so the compiled binary actually executes after /// the build completes — `ChainStepHarness.command` models a single /// process, so the build-then-run sequence must collapse to one `sh -c`. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let shim = probe_shim(); - let driver = "\nint main(void) {\n const char *prev = getenv(\"NYX_PREV_OUTPUT\");\n if (prev) fputs(prev, stdout);\n return 0;\n}\n"; + let mut driver = String::from( + "\nint main(void) {\n const char *prev = getenv(\"NYX_PREV_OUTPUT\");\n if (prev) fputs(prev, stdout);\n", + ); + if let Some(t) = terminal { + let callee = c_string_literal(&t.sink_callee); + let sentinel = c_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + " __nyx_probe({callee}, 1, prev ? prev : \"\");\n puts({sentinel});\n fflush(stdout);\n", + )); + } + driver.push_str(" return 0;\n}\n"); let source = format!("{shim}{driver}"); ChainStepHarness { source, @@ -415,6 +430,12 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe C double-quoted literal embedding. +fn c_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + /// Emit a C harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { let shape = detect_shape(spec); @@ -875,7 +896,7 @@ mod tests { // source, that `prev_output` rides through `extra_env`, and // that the build-then-run command stays in one `sh -c` so the // sandbox sees a single process. - let step = chain_step(Some(b"prev-output")); + let step = chain_step(Some(b"prev-output"), None); assert!( step.source.contains("__nyx_probe shim (Phase 06"), "probe_shim banner missing from chain step source", diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 56051655..72c7ad43 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -15,7 +15,7 @@ //! Build step: `prepare_cpp()` in `build_sandbox.rs` runs //! `g++ -O0 -std=c++17 -o nyx_harness main.cpp` in the workdir. -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -325,24 +325,42 @@ impl LangEmitter for CppEmitter { ) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } /// Phase 26 — C++ chain-step harness. /// /// Splices the C++ probe shim ([`probe_shim`]) ahead of a minimal driver -/// that reads `NYX_PREV_OUTPUT` and forwards it on stdout. Same -/// rationale as the C sibling: the inline shim helpers become callable -/// from a future sink-rewrite pass without a separate translation unit; -/// unreferenced inline functions stay quiet under default `c++` flags. +/// that reads `NYX_PREV_OUTPUT` and forwards it on stdout. When the +/// step is the chain's terminal step (`terminal == Some(_)`) the driver +/// also calls `__nyx_probe(callee, std::string(prev))` and emits the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` for the chain. /// /// Shell-wraps `c++` + run so the compiled binary actually executes /// after the build completes (see C-side commentary for the rationale). -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let shim = probe_shim(); - let driver = "\nint main() {\n const char *prev = std::getenv(\"NYX_PREV_OUTPUT\");\n if (prev) std::fputs(prev, stdout);\n return 0;\n}\n"; + let mut driver = String::from( + "\nint main() {\n const char *prev = std::getenv(\"NYX_PREV_OUTPUT\");\n if (prev) std::fputs(prev, stdout);\n", + ); + if let Some(t) = terminal { + let callee = cpp_string_literal(&t.sink_callee); + let sentinel = cpp_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + " __nyx_probe({callee}, std::string(prev ? prev : \"\"));\n std::puts({sentinel});\n std::fflush(stdout);\n", + )); + } + driver.push_str(" return 0;\n}\n"); let source = format!("{shim}{driver}"); ChainStepHarness { source, @@ -364,6 +382,12 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe C++ double-quoted literal embedding. +fn cpp_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + /// Emit a C++ harness for `spec`. pub fn emit(spec: &HarnessSpec) -> Result { let shape = detect_shape(spec); @@ -742,7 +766,7 @@ mod tests { // shim banner is present and lands before `int main`, that // `__nyx_install_crash_guard` is reachable, prev_output rides // through `extra_env`, and build-then-run stays one `sh -c`. - let step = chain_step(Some(b"prev-output")); + let step = chain_step(Some(b"prev-output"), None); assert!( step.source.contains("__nyx_probe shim (Phase 06"), "probe_shim banner missing from chain step source", diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 933a97c7..c84a4fd8 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -37,7 +37,7 @@ //! Build container: `nyx-build-go:{toolchain_id}` (deferred; §19.1). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -76,28 +76,44 @@ impl LangEmitter for GoEmitter { materialize_go(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } /// Phase 26 — Go chain-step harness. /// /// Splices the Go probe shim ([`probe_shim`]) ahead of a minimal driver -/// that reads `NYX_PREV_OUTPUT` and forwards it on stdout. The composite -/// re-verifier swaps the trailing forward for the next member's -/// payload-injection prologue when running a multi-step chain; the shim -/// has to be in the same compilation unit so a chain step that terminates -/// at a sink can drive the `__nyx_probe` channel directly. +/// that reads `NYX_PREV_OUTPUT` and forwards it on stdout. When the +/// step is the chain's terminal step the driver also calls +/// `__nyx_probe(callee, prev)` and prints the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` for the chain. /// /// Imports are the union of the driver imports (`fmt`, `os`) and the /// shim's [`SHIM_IMPORTS`], deduped + sorted so `go run step.go` /// compiles in a single command. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let imports = chain_step_imports(); let shim = probe_shim(); - let driver = - "func main() {\n prev := os.Getenv(\"NYX_PREV_OUTPUT\")\n fmt.Print(prev)\n}\n"; + let mut driver = String::from( + "func main() {\n prev := os.Getenv(\"NYX_PREV_OUTPUT\")\n fmt.Print(prev)\n", + ); + if let Some(t) = terminal { + let callee = go_string_literal(&t.sink_callee); + let sentinel = go_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + " __nyx_probe({callee}, prev)\n fmt.Println({sentinel})\n", + )); + } + driver.push_str("}\n"); let source = format!("package main\n\nimport (\n{imports})\n{shim}\n{driver}"); ChainStepHarness { source, @@ -115,6 +131,12 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe Go double-quoted literal embedding. +fn go_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + /// Sorted, deduped tab-prefixed import lines covering the driver's /// `fmt` + `os` plus everything in [`SHIM_IMPORTS`]. fn chain_step_imports() -> String { @@ -968,7 +990,7 @@ mod tests { #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { - let step = chain_step(Some(b"")); + let step = chain_step(Some(b""), None); assert!( step.source.contains("__nyx_probe"), "Go chain step must splice the probe shim" diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index 41c34d2f..1caf3686 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -36,7 +36,7 @@ //! Build container: `nyx-build-java:{toolchain_id}` (deferred; §19.1). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -75,16 +75,23 @@ impl LangEmitter for JavaEmitter { materialize_java(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } /// Phase 26 — Java chain-step harness. /// /// Emits a `Step.java` class whose `main` reads `NYX_PREV_OUTPUT` and -/// forwards it on stdout. The command shell-wraps `javac` + `java` so -/// the step actually runs after the build step completes (the +/// forwards it on stdout. When the step is the chain's terminal step +/// the `main` body also calls `__nyx_probe(callee, prev)` and prints +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` for the chain. The command shell-wraps `javac` + `java` +/// so the step actually runs after the build step completes (the /// `ChainStepHarness.command` slot models a single process). /// /// The Java probe shim (`__nyx_probe`, `__nyx_install_crash_guard`, @@ -95,10 +102,23 @@ impl LangEmitter for JavaEmitter { /// fully-qualified `java.util.TreeMap` / `java.io.FileWriter` / /// `java.nio.charset.StandardCharsets`, so no extra `import` lines /// are needed beyond what stock Java implicitly imports. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let shim = probe_shim(); + let mut body = String::from( + " String prev = System.getenv(\"NYX_PREV_OUTPUT\");\n if (prev == null) prev = \"\";\n System.out.print(prev);\n", + ); + if let Some(t) = terminal { + let callee = java_string_literal(&t.sink_callee); + let sentinel = java_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + body.push_str(&format!( + " __nyx_probe({callee}, prev);\n System.out.println({sentinel});\n System.out.flush();\n", + )); + } let source = format!( - "public class Step {{\n{shim}\n public static void main(String[] args) {{\n String prev = System.getenv(\"NYX_PREV_OUTPUT\");\n if (prev == null) prev = \"\";\n System.out.print(prev);\n }}\n}}\n" + "public class Step {{\n{shim}\n public static void main(String[] args) {{\n{body} }}\n}}\n" ); ChainStepHarness { source, @@ -120,6 +140,12 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe Java double-quoted literal embedding. +fn java_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + // ── Phase 14: shape detector ───────────────────────────────────────────────── /// Concrete per-file shape resolved by reading the entry source. @@ -1142,7 +1168,7 @@ mod tests { #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { - let step = chain_step(Some(b"")); + let step = chain_step(Some(b""), None); assert!( step.source.contains("__nyx_probe"), "Java chain step must splice the probe shim" diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index fd43cd83..5ba18cf7 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -15,7 +15,7 @@ //! - [`PayloadSlot::Argv`] — coerced to positional `Param(0)` by build_call. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{js_shared, ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{js_shared, ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; @@ -44,8 +44,12 @@ impl LangEmitter for JavaScriptEmitter { materialize_node(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - js_shared::chain_step(prev_output, /* typescript = */ false) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + js_shared::chain_step(prev_output, /* typescript = */ false, terminal) } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 989a01bb..0a08e0a2 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -24,7 +24,7 @@ //! which preserves the pre-Phase-13 behaviour. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::utils::project::DetectedFramework; @@ -454,12 +454,27 @@ pub fn emit(spec: &HarnessSpec, is_typescript: bool) -> Result, is_typescript: bool) -> ChainStepHarness { +/// driver that reads `NYX_PREV_OUTPUT` and forwards it on stdout. When +/// the step is the chain's terminal step the driver also calls +/// `__nyx_probe(callee, prev)` and prints the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` for the chain. +pub fn chain_step( + prev_output: Option<&[u8]>, + is_typescript: bool, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let probe = probe_shim(); - let driver = "\nprocess.stdout.write(process.env.NYX_PREV_OUTPUT || '');\n"; + let mut driver = String::from( + "\nconst __nyx_prev = process.env.NYX_PREV_OUTPUT || '';\nprocess.stdout.write(__nyx_prev);\n", + ); + if let Some(t) = terminal { + let callee = js_string_literal(&t.sink_callee); + let sentinel = js_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + "__nyx_probe({callee}, __nyx_prev);\nconsole.log({sentinel});\n", + )); + } // The chain-step source is pure JS even under the TypeScript emitter // — the probe shim uses no TS-specific syntax — so we keep the `.ts` // filename intent (so the workdir reflects which emitter produced @@ -498,6 +513,12 @@ pub fn chain_step(prev_output: Option<&[u8]>, is_typescript: bool) -> ChainStepH } } +/// Escape a string for safe JS double-quoted literal embedding. +fn js_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + /// Public wrapper to detect the shape for a finalised [`HarnessSpec`]. pub fn detect_shape(spec: &HarnessSpec) -> JsShape { let entry_source = read_entry_source(&spec.entry_file); diff --git a/src/dynamic/lang/mod.rs b/src/dynamic/lang/mod.rs index 2c24dc7c..91df721f 100644 --- a/src/dynamic/lang/mod.rs +++ b/src/dynamic/lang/mod.rs @@ -81,6 +81,41 @@ impl ChainStepHarness { /// step's environment. Stable surface — kept distinct from /// `NYX_PAYLOAD` so a chain step can read both at once. pub const PREV_OUTPUT_ENV: &'static str = "NYX_PREV_OUTPUT"; + + /// Sentinel printed to stdout by the terminal chain step so the + /// runner's [`crate::dynamic::sandbox::SandboxOutcome::sink_hit`] + /// fold can flip to `true` on a successful end-to-end compose. + /// Mirrors the per-language tracer sentinel used by the regular + /// harness emitters; the runner detects the byte sequence in + /// stdout/stderr. + pub const SINK_HIT_SENTINEL: &'static str = "__NYX_SINK_HIT__"; +} + +/// Phase 26 — terminal-step descriptor for [`LangEmitter::compose_chain_step`]. +/// +/// Carries the chain's terminal sink callee so the emitter can rewrite +/// the final step's source to invoke the probe shim with the threaded +/// payload and emit the [`ChainStepHarness::SINK_HIT_SENTINEL`]; the +/// composite reverifier then promotes its verdict from `Inconclusive` +/// to `Confirmed` when the runner observes the sentinel on the chain's +/// last step. +/// +/// Non-terminal steps pass `None` so they retain the prev-output echo +/// behaviour. +#[derive(Debug, Clone)] +pub struct ChainStepTerminal { + /// Callee name for the chain's terminal sink (e.g. `"eval"`, + /// `"os.system"`, `"setattr"`). Used as the first argument to + /// `__nyx_probe(callee, prev)` so the per-language probe shim + /// records the witness. Kept as `String` rather than `&str` so the + /// reverifier can hand-roll a `ChainStepTerminal` from a + /// [`crate::chain::finding::ChainSink`] without lifetime gymnastics. + pub sink_callee: String, + /// Capability bits associated with the sink. Today the emitters do + /// not read this — recorded so a future per-cap sink-fire shape + /// dispatcher can pick the right invocation idiom without re-walking + /// the chain. + pub sink_cap_bits: u32, } /// Per-language harness emitter contract. @@ -135,25 +170,39 @@ pub trait LangEmitter { /// Phase 26 — Track G.3: build one step of a chain-composite harness. /// /// `prev_output` carries the previous step's stdout (or `None` for - /// the chain's entry step). The returned [`ChainStepHarness`] - /// reads `NYX_PREV_OUTPUT` from its env to fold the chained input - /// into the step's behaviour and (when the step terminates at a - /// sink) invokes the Phase 06 `__nyx_probe` shim so the runner's - /// probe channel observes the sink fire. + /// the chain's entry step). `terminal` is `Some` only on the + /// chain's last step and carries the sink callee so the emitter + /// can splice in a `__nyx_probe(callee, prev)` call plus the + /// [`ChainStepHarness::SINK_HIT_SENTINEL`] stdout banner that the + /// runner detects via [`crate::dynamic::sandbox::SandboxOutcome::sink_hit`]. /// /// Default impl produces a portable POSIX-shell stub that echoes - /// the previous step's output verbatim. Concrete emitters override - /// to splice in the language-native probe shim. - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - default_chain_step(prev_output) + /// the previous step's output verbatim, and (when `terminal` is + /// set) appends a `printf '__NYX_SINK_HIT__\n'` line. Concrete + /// emitters override to splice in the language-native probe shim. + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + default_chain_step(prev_output, terminal) } } /// Default chain-step harness. Emitted by [`LangEmitter::compose_chain_step`] /// when an emitter does not override the trait method. -pub fn default_chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +pub fn default_chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { + let mut script = String::from("#!/bin/sh\nprintf '%s' \"${NYX_PREV_OUTPUT:-}\"\n"); + if terminal.is_some() { + script.push_str("printf '\\n"); + script.push_str(ChainStepHarness::SINK_HIT_SENTINEL); + script.push_str("\\n'\n"); + } ChainStepHarness { - source: "#!/bin/sh\nprintf '%s' \"${NYX_PREV_OUTPUT:-}\"\n".to_owned(), + source: script, filename: "step.sh".to_owned(), command: vec!["sh".to_owned(), "step.sh".to_owned()], extra_env: prev_output @@ -172,9 +221,13 @@ pub fn default_chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { /// /// Returns the lang-agnostic shell stub when `lang` has no registered /// emitter so callers do not need to special-case that path. -pub fn compose_chain_step(lang: Lang, prev_output: Option<&[u8]>) -> ChainStepHarness { - dispatch(lang, |e| e.compose_chain_step(prev_output)) - .unwrap_or_else(|| default_chain_step(prev_output)) +pub fn compose_chain_step( + lang: Lang, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { + dispatch(lang, |e| e.compose_chain_step(prev_output, terminal)) + .unwrap_or_else(|| default_chain_step(prev_output, terminal)) } /// Public free-fn dispatcher for [`LangEmitter::materialize_runtime`]. diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index bc010dd1..68ef8571 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -29,7 +29,7 @@ //! Build container: `nyx-build-php:{toolchain_id}` (deferred; §19.1). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -68,8 +68,12 @@ impl LangEmitter for PhpEmitter { materialize_php(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } @@ -77,14 +81,25 @@ impl LangEmitter for PhpEmitter { /// /// Splices the PHP probe shim ([`probe_shim`]) in front of a minimal /// driver that reads `NYX_PREV_OUTPUT` via `getenv()` and forwards it -/// on stdout. The composite re-verifier swaps the trailing forward for -/// the next member's payload-injection prologue when running a -/// multi-step chain; the shim has to be in the same file so a chain -/// step that terminates at a sink can also drive the `__nyx_probe` -/// channel. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +/// on stdout. When the step is the chain's terminal step the driver +/// also calls `__nyx_probe(callee, [prev])` and emits the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` for the chain. +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let shim = probe_shim(); - let driver = "$prev = getenv(\"NYX_PREV_OUTPUT\");\nif ($prev === false) { $prev = \"\"; }\necho $prev;\n"; + let mut driver = String::from( + "$prev = getenv(\"NYX_PREV_OUTPUT\");\nif ($prev === false) { $prev = \"\"; }\necho $prev;\n", + ); + if let Some(t) = terminal { + let callee = php_string_literal(&t.sink_callee); + let sentinel = php_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + "__nyx_probe({callee}, [$prev]);\necho \"\\n\" . {sentinel} . \"\\n\";\n", + )); + } let source = format!(") -> ChainStepHarness { } } +/// Escape a string for safe PHP double-quoted literal embedding. +/// Backslash and double-quote escape only; bytes outside printable +/// ASCII are left to PHP's source decoder. +fn php_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + // ── Phase 15: shape detector ───────────────────────────────────────────────── /// Concrete per-file shape resolved by reading the entry source. @@ -789,7 +812,7 @@ mod tests { #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { - let step = chain_step(Some(b"")); + let step = chain_step(Some(b""), None); assert!( step.source.contains("__nyx_probe"), "PHP chain step must splice the probe shim" diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index 62441cde..c50fda51 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -23,7 +23,7 @@ //! - Other slots produce [`UnsupportedReason::PayloadSlotUnsupported`]. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::utils::project::DetectedFramework; @@ -66,20 +66,38 @@ impl LangEmitter for PythonEmitter { materialize_python(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } /// Phase 26 — Python chain-step harness. /// /// Splices the Python probe shim ([`probe_shim`]) in front of a minimal -/// driver that reads `NYX_PREV_OUTPUT` and forwards it on stdout. The -/// composite re-verifier swaps the trailing forward for the next member's -/// payload-injection prologue when running a multi-step chain. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +/// driver that reads `NYX_PREV_OUTPUT` and forwards it on stdout. When +/// `terminal` is `Some`, the driver also calls `__nyx_probe(callee, +/// prev)` so the spliced shim records a witness, then prints the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` on the terminal step. +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let probe = probe_shim(); - let driver = "\nimport os, sys\nprev = os.environ.get('NYX_PREV_OUTPUT', '')\nsys.stdout.write(prev)\nsys.stdout.flush()\n"; + let mut driver = String::from( + "\nimport os, sys\nprev = os.environ.get('NYX_PREV_OUTPUT', '')\nsys.stdout.write(prev)\nsys.stdout.flush()\n", + ); + if let Some(t) = terminal { + let callee = python_string_literal(&t.sink_callee); + driver.push_str(&format!( + "__nyx_probe({callee}, prev)\nprint({sentinel}, flush=True)\n", + sentinel = python_string_literal(ChainStepHarness::SINK_HIT_SENTINEL), + )); + } ChainStepHarness { source: format!("{probe}{driver}"), filename: "step.py".to_owned(), @@ -96,6 +114,14 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe Python single-quoted literal embedding. +/// Conservative: backslash + single-quote escape only; bytes outside +/// printable ASCII are left to Python's UTF-8 source decoder. +fn python_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('\'', "\\'"); + format!("'{escaped}'") +} + // ── Phase 12: shape detector ───────────────────────────────────────────────── /// Concrete per-file shape resolved by reading the entry source. diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 531c083a..e9c2ec18 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -27,7 +27,7 @@ //! Build: no compilation step. Command is `ruby harness.rb`. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use std::path::PathBuf; @@ -65,8 +65,12 @@ impl LangEmitter for RubyEmitter { materialize_ruby(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } @@ -74,12 +78,25 @@ impl LangEmitter for RubyEmitter { /// /// Splices the Ruby probe shim ([`probe_shim`]) in front of a minimal /// driver that reads `NYX_PREV_OUTPUT` from `ENV` and forwards it on -/// stdout. Mirrors the Python / Node steps: a step that terminates at -/// a sink needs the shim in the same file so it can drive the -/// `__nyx_probe` channel. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +/// stdout. When the step is the chain's terminal step the driver also +/// calls `__nyx_probe(callee, prev)` and emits the +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` for the chain. +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let shim = probe_shim(); - let driver = "prev = ENV[\"NYX_PREV_OUTPUT\"] || \"\"\n$stdout.write(prev)\n"; + let mut driver = String::from( + "prev = ENV[\"NYX_PREV_OUTPUT\"] || \"\"\n$stdout.write(prev)\n", + ); + if let Some(t) = terminal { + let callee = ruby_string_literal(&t.sink_callee); + let sentinel = ruby_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + "__nyx_probe({callee}, prev)\nputs {sentinel}\n$stdout.flush\n", + )); + } let source = format!("{shim}\n{driver}"); ChainStepHarness { source, @@ -97,6 +114,12 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe Ruby double-quoted literal embedding. +fn ruby_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + // ── Phase 15: shape detector ───────────────────────────────────────────────── /// Concrete per-file shape resolved by reading the entry source. @@ -867,7 +890,7 @@ mod tests { #[test] fn chain_step_splices_probe_shim_for_composite_reverify() { - let step = chain_step(Some(b"")); + let step = chain_step(Some(b""), None); assert!( step.source.contains("__nyx_probe"), "Ruby chain step must splice the probe shim" diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index f01b4335..236b6915 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -22,7 +22,7 @@ //! HTML_ESCAPE is n/a for Rust (§15.4). use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec, PayloadSlot}; use crate::evidence::UnsupportedReason; use crate::labels::Cap; @@ -64,8 +64,12 @@ impl LangEmitter for RustEmitter { materialize_rust(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - chain_step(prev_output) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + chain_step(prev_output, terminal) } } @@ -78,9 +82,27 @@ impl LangEmitter for RustEmitter { /// the symbols. Instead the step ships a companion `Cargo.toml` /// pinning `libc = "0.2"` via [`ChainStepHarness::extra_files`] and /// drives the build through `cargo run --quiet`. -fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { +/// +/// When `terminal` is set, the driver also calls +/// `__nyx_probe(callee, &[&prev])` and prints +/// [`ChainStepHarness::SINK_HIT_SENTINEL`] so the runner flips +/// `sink_hit` on the chain's last step. +fn chain_step( + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, +) -> ChainStepHarness { let shim = probe_shim(); - let driver = "use std::env;\nuse std::io::{self, Write};\n\nfn main() {\n let prev = env::var(\"NYX_PREV_OUTPUT\").unwrap_or_default();\n let _ = io::stdout().write_all(prev.as_bytes());\n}\n"; + let mut driver = String::from( + "use std::env;\nuse std::io::{self, Write};\n\nfn main() {\n let prev = env::var(\"NYX_PREV_OUTPUT\").unwrap_or_default();\n let _ = io::stdout().write_all(prev.as_bytes());\n", + ); + if let Some(t) = terminal { + let callee = rust_string_literal(&t.sink_callee); + let sentinel = rust_string_literal(ChainStepHarness::SINK_HIT_SENTINEL); + driver.push_str(&format!( + " __nyx_probe({callee}, &[prev.as_str()]);\n println!({sentinel});\n", + )); + } + driver.push_str("}\n"); let source = format!("{shim}\n{driver}"); let cargo_toml = "[package]\n\ name = \"nyx-chain-step\"\n\ @@ -108,6 +130,12 @@ fn chain_step(prev_output: Option<&[u8]>) -> ChainStepHarness { } } +/// Escape a string for safe Rust double-quoted literal embedding. +fn rust_string_literal(s: &str) -> String { + let escaped = s.replace('\\', "\\\\").replace('"', "\\\""); + format!("\"{escaped}\"") +} + /// Phase 09 — Track D.2: synthesise a `Cargo.toml` that pins every /// captured crate dep. The base cap-driven dep set lives in /// [`generate_cargo_toml`]; this function layers the user's direct @@ -986,7 +1014,7 @@ mod tests { // shim references `libc::*` so the step also ships a companion // `Cargo.toml` via `extra_files` and drives the build through // `cargo run --quiet` rather than single-file `rustc`. - let step = chain_step(Some(b"prev-output")); + let step = chain_step(Some(b"prev-output"), None); assert!( step.source.contains("__nyx_probe shim (Phase 06"), "probe_shim banner missing from chain step source", @@ -1048,7 +1076,7 @@ mod tests { #[test] fn chain_step_emits_cargo_toml_with_libc_dep() { - let step = chain_step(None); + let step = chain_step(None, None); let cargo = step .extra_files .iter() diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index 9134b60c..e880e513 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -15,7 +15,7 @@ //! runtime ignores. use crate::dynamic::environment::{Environment, RuntimeArtifacts}; -use crate::dynamic::lang::{js_shared, ChainStepHarness, HarnessSource, LangEmitter}; +use crate::dynamic::lang::{js_shared, ChainStepHarness, ChainStepTerminal, HarnessSource, LangEmitter}; use crate::dynamic::spec::{EntryKind, HarnessSpec}; use crate::evidence::UnsupportedReason; @@ -47,8 +47,12 @@ impl LangEmitter for TypeScriptEmitter { js_shared::materialize_node(env) } - fn compose_chain_step(&self, prev_output: Option<&[u8]>) -> ChainStepHarness { - js_shared::chain_step(prev_output, /* typescript = */ true) + fn compose_chain_step( + &self, + prev_output: Option<&[u8]>, + terminal: Option<&ChainStepTerminal>, + ) -> ChainStepHarness { + js_shared::chain_step(prev_output, /* typescript = */ true, terminal) } } diff --git a/tests/chain_reverify.rs b/tests/chain_reverify.rs index 3329f4ff..3e0ef1f2 100644 --- a/tests/chain_reverify.rs +++ b/tests/chain_reverify.rs @@ -24,7 +24,7 @@ use nyx_scanner::chain::reverify::{ CompositeReverifier, chain_step_specs, reverify_chain_with, reverify_top_chains_with, }; use nyx_scanner::commands::scan::Diag; -use nyx_scanner::dynamic::lang::{ChainStepHarness, compose_chain_step}; +use nyx_scanner::dynamic::lang::{ChainStepHarness, ChainStepTerminal, compose_chain_step}; use nyx_scanner::dynamic::verify::VerifyOptions; use nyx_scanner::evidence::{InconclusiveReason, UnsupportedReason, VerifyResult, VerifyStatus}; use nyx_scanner::surface::{SourceLocation, SurfaceMap}; @@ -185,7 +185,7 @@ fn compose_chain_step_threads_prev_output_for_every_emitter() { Lang::C, Lang::Cpp, ] { - let step = compose_chain_step(lang, Some(prev)); + let step = compose_chain_step(lang, Some(prev), None); assert!( step.extra_env .iter() @@ -195,15 +195,59 @@ fn compose_chain_step_threads_prev_output_for_every_emitter() { ); assert!(!step.source.is_empty(), "{lang:?} step source must be non-empty"); assert!(!step.command.is_empty(), "{lang:?} step command must be non-empty"); + assert!( + !step.source.contains(ChainStepHarness::SINK_HIT_SENTINEL), + "{lang:?} non-terminal step must NOT carry the sink-hit sentinel; got source:\n{}", + step.source, + ); } } #[test] fn compose_chain_step_with_no_prev_output_has_empty_extra_env() { - let step = compose_chain_step(Lang::Python, None); + let step = compose_chain_step(Lang::Python, None, None); assert!(step.extra_env.is_empty()); } +#[test] +fn compose_chain_step_terminal_splices_sink_hit_sentinel_for_every_emitter() { + // Phase 26 deliverable: when `terminal` is `Some`, every emitter + // must splice the `SINK_HIT_SENTINEL` into the step's source so a + // successful end-to-end compose flips + // `SandboxOutcome::sink_hit` and the composite reverifier can + // promote its verdict from `Inconclusive` to `Confirmed`. + let prev = b"terminal-witness".as_slice(); + let terminal = ChainStepTerminal { + sink_callee: "eval".into(), + sink_cap_bits: 0x400, + }; + for lang in [ + Lang::Python, + Lang::Rust, + Lang::JavaScript, + Lang::TypeScript, + Lang::Go, + Lang::Java, + Lang::Php, + Lang::Ruby, + Lang::C, + Lang::Cpp, + ] { + let step = compose_chain_step(lang, Some(prev), Some(&terminal)); + assert!( + step.source.contains(ChainStepHarness::SINK_HIT_SENTINEL), + "{lang:?} terminal step must splice {} into source; got source:\n{}", + ChainStepHarness::SINK_HIT_SENTINEL, + step.source, + ); + assert!( + step.source.contains("eval"), + "{lang:?} terminal step must reference the sink callee `eval`; got source:\n{}", + step.source, + ); + } +} + #[test] fn chain_step_specs_aligns_results_to_member_order_and_reports_missing_diags() { let chain = ChainFinding {