From 6f58921a171d04a18e57acde3f6cbdaa4dfd4443 Mon Sep 17 00:00:00 2001 From: pitboss Date: Fri, 22 May 2026 04:05:18 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0013 (20260522T043516Z-29b8) --- src/dynamic/lang/java.rs | 169 ++++++++++++---------------------- src/dynamic/lang/js_shared.rs | 112 +++++++++------------- src/dynamic/lang/php.rs | 134 ++++++++++----------------- tests/xpath_corpus.rs | 40 +++++++- 4 files changed, 192 insertions(+), 263 deletions(-) diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index b879d573..199e38fe 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -1354,112 +1354,18 @@ pub fn emit_xpath_harness(spec: &HarnessSpec) -> HarnessSource { } else { spec.entry_name.clone() }; - let uses_real_xpath = entry_source.contains("javax.xml.xpath"); - - let main_body = if uses_real_xpath { - format!( - r#" // Phase 07 tier-(a): reflectively invoke the fixture's - // `run(String)` so the real `javax.xml.xpath.XPath.evaluate` - // call against the staged corpus document runs, then count - // the returned `NodeList` nodes. Falls back to the inline - // matcher when reflection fails so the harness still produces - // a verdict on a fixture whose `run` signature does not match. - int count = -1; - try {{ - Class entry = Class.forName("{entry_fqn}"); - Method m = entry.getDeclaredMethod("{entry_method}", String.class); - m.setAccessible(true); - Object result = m.invoke(null, payload); - if (result instanceof NodeList) {{ - count = ((NodeList) result).getLength(); - }} - }} catch (ClassNotFoundException | NoSuchMethodException - | IllegalAccessException e) {{ - // Fixture shape did not match (String) -> NodeList — fall - // through to the synthetic matcher below. - }} catch (InvocationTargetException ite) {{ - // The fixture itself threw (malformed XPath, parse error, - // ...); treat as a 0-node return so a benign fixture that - // rejects the payload stays NotConfirmed. - count = 0; - }} - if (count < 0) {{ - count = nyxXpathSelect(expr); - }}"# - ) - } else { - r#" // No real javax.xml.xpath import on the entry source — fall - // back to the inline matcher path so the harness still - // produces a verdict. - int count = nyxXpathSelect(expr);"# - .to_owned() - }; - - let imports = if uses_real_xpath { - "import java.lang.reflect.InvocationTargetException;\n\ - import java.lang.reflect.Method;\n\ - import org.w3c.dom.NodeList;\n" - } else { - "" - }; let source = format!( r#"// Nyx dynamic harness — XPATH_INJECTION javax.xml.xpath.XPath.evaluate (Phase 07 / Track J.5). import java.io.FileWriter; import java.io.IOException; -import java.util.Arrays; -import java.util.List; -import java.util.regex.Matcher; -import java.util.regex.Pattern; -{imports} +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import org.w3c.dom.NodeList; + public class NyxHarness {{ {shim} - static final String[] NYX_XPATH_USERS = new String[] {{ "alice", "bob", "carol" }}; - - static int nyxXpathSelect(String expr) {{ - String needle = "//user[@name="; - if (!expr.startsWith(needle)) return 0; - String rest = expr.substring(needle.length()); - if (!rest.endsWith("]")) return 0; - String predicate = rest.substring(0, rest.length() - 1); - - Matcher single = Pattern.compile("^'([^']*)'(.*)$").matcher(predicate); - if (single.find()) {{ - String literal = single.group(1); - String tail = single.group(2).trim(); - if (tail.isEmpty() || tail.equals("]")) {{ - int count = 0; - for (String u : NYX_XPATH_USERS) if (u.equals(literal)) count++; - return count; - }} - if (Pattern.compile("^or\\s+", Pattern.CASE_INSENSITIVE).matcher(tail).find()) {{ - return NYX_XPATH_USERS.length; - }} - }} - Matcher dbl = Pattern.compile("^\"([^\"]*)\"\\s*$").matcher(predicate); - if (dbl.find()) {{ - String literal = dbl.group(1); - int count = 0; - for (String u : NYX_XPATH_USERS) if (u.equals(literal)) count++; - return count; - }} - if (Pattern.compile("^concat\\(", Pattern.CASE_INSENSITIVE).matcher(predicate).find()) {{ - Matcher parts = Pattern.compile("'([^']*)'").matcher(predicate); - StringBuilder joined = new StringBuilder(); - while (parts.find()) {{ - String p = parts.group(1); - if (p.equals(",\"")) continue; - joined.append(p); - }} - String result = joined.toString().replace(",\"'\",", "'"); - int count = 0; - for (String u : NYX_XPATH_USERS) if (u.equals(result)) count++; - return count; - }} - return NYX_XPATH_USERS.length; - }} - static void nyxXpathProbe(String expr, int nodesReturned) {{ String p = System.getenv("NYX_PROBE_PATH"); if (p == null || p.isEmpty()) return; @@ -1488,7 +1394,38 @@ public class NyxHarness {{ String payload = System.getenv("NYX_PAYLOAD"); if (payload == null) payload = ""; String expr = "//user[@name='" + payload + "']"; -{main_body} + // Phase 07 tier-(a): reflectively invoke the fixture's + // `run(String)` so the real `javax.xml.xpath.XPath.evaluate` + // call against the staged corpus document runs, then count + // the returned `NodeList` nodes. Missing `javax.xml.xpath` + // / `org.w3c.dom` on the JDK is the only structural reason + // the reflective lookup fails; in that case we emit the + // conventional `NYX_IMPORT_ERROR:` stderr marker plus + // `System.exit(77)` so the runner maps the outcome to + // `RunError::BuildFailed` and the e2e SKIP branch fires. + int count; + try {{ + Class entry = Class.forName("{entry_fqn}"); + Method m = entry.getDeclaredMethod("{entry_method}", String.class); + m.setAccessible(true); + Object result = m.invoke(null, payload); + if (result instanceof NodeList) {{ + count = ((NodeList) result).getLength(); + }} else {{ + count = 0; + }} + }} catch (ClassNotFoundException | NoSuchMethodException + | IllegalAccessException e) {{ + System.err.println("NYX_IMPORT_ERROR: " + e.getClass().getName() + ": " + e.getMessage()); + System.exit(77); + return; + }} catch (InvocationTargetException ite) {{ + // The fixture itself threw (malformed XPath, parse error, + // ...); treat as a 0-node return so a benign fixture that + // rejects the payload stays NotConfirmed. + count = 0; + }} + System.out.println("__NYX_XPATH_TIER_A__"); nyxXpathProbe(expr, count); System.out.println("__NYX_SINK_HIT__"); StringBuilder body = new StringBuilder(64); @@ -3598,7 +3535,7 @@ mod tests { } #[test] - fn emit_xpath_harness_drives_fixture_through_real_xpath_when_imported() { + fn emit_xpath_harness_routes_through_real_xpath_reflectively() { let dir = std::env::temp_dir().join("nyx_phase07_test_drive_fixture"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -3634,15 +3571,16 @@ mod tests { "tier-(a) harness must cast the result to NodeList and count nodes", ); assert!( - h.source.contains("count = nyxXpathSelect(expr);"), - "tier-(a) harness must preserve the inline matcher as a fallback", + h.source.contains("__NYX_XPATH_TIER_A__"), + "tier-(a) harness must emit the tier-(a) stdout marker after the real reflective invoke: {}", + h.source ); let _ = std::fs::remove_dir_all(&dir); } #[test] - fn emit_xpath_harness_falls_back_to_inline_matcher_without_xpath_import() { - let dir = std::env::temp_dir().join("nyx_phase07_test_no_xpath_import"); + fn emit_xpath_harness_drops_inline_matcher_fallback() { + let dir = std::env::temp_dir().join("nyx_phase07_test_no_inline_matcher"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = write_servlet_fixture( @@ -3655,16 +3593,27 @@ mod tests { spec.entry_name = "run".into(); let h = emit_xpath_harness(&spec); assert!( - !h.source.contains("import org.w3c.dom.NodeList;"), - "fallback path must not import NodeList", + !h.source.contains("nyxXpathSelect"), + "harness must not carry the inline `nyxXpathSelect` matcher; tier-(a) reflective invoke is the only path", ); assert!( - !h.source.contains("Class.forName(\"Vuln\")"), - "fallback path must not invoke the fixture reflectively", + !h.source.contains("NYX_XPATH_USERS"), + "harness must not carry the inline `NYX_XPATH_USERS` table; tier-(a) reflective invoke is the only path", ); assert!( - h.source.contains("int count = nyxXpathSelect(expr);"), - "fallback path must keep the inline matcher as the primary count source", + h.source.contains("NYX_IMPORT_ERROR:") && h.source.contains("System.exit(77)"), + "harness must emit `NYX_IMPORT_ERROR:` stderr marker + `System.exit(77)` on reflective lookup failure: {}", + h.source + ); + assert!( + h.source.contains("__NYX_XPATH_TIER_A__"), + "harness must emit the tier-(a) stdout marker: {}", + h.source + ); + assert!( + h.source.contains("import org.w3c.dom.NodeList;") + && h.source.contains("import java.lang.reflect.Method;"), + "harness must always import the reflective invocation path; the synthetic-only branch is gone", ); } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index 5f0037ee..393e028d 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1225,69 +1225,37 @@ pub fn emit_xpath_harness(spec: &HarnessSpec) -> HarnessSource { let shim = probe_shim(); let corpus_filename = crate::dynamic::stubs::xpath_document::XPATH_CORPUS_FILENAME; let corpus_xml = crate::dynamic::stubs::xpath_document::XPATH_CORPUS_XML; - let entry_source = read_entry_source(&spec.entry_file); let entry_stem = derive_js_entry_stem(&spec.entry_file); let entry_name = if spec.entry_name.is_empty() { "run".to_owned() } else { spec.entry_name.clone() }; - let uses_real_xpath = entry_source.contains("require('xpath')") - || entry_source.contains("require(\"xpath\")"); let body = format!( r#"// Nyx dynamic harness — XPATH_INJECTION xpath.select (Phase 07 / Track J.5). {shim} -const NYX_XPATH_USERS = ['alice', 'bob', 'carol']; - -function nyxXpathSelect(expr) {{ - const needle = "//user[@name="; - if (!expr.startsWith(needle)) return 0; - const rest = expr.slice(needle.length); - if (!rest.endsWith("]")) return 0; - const predicate = rest.slice(0, -1); - - let m = predicate.match(/^'([^']*)'(.*)$/); - if (m) {{ - const literal = m[1]; - const tail = m[2].trim(); - if (tail === '' || tail === ']') {{ - return NYX_XPATH_USERS.filter((u) => u === literal).length; - }} - if (/^or\s+/i.test(tail)) {{ - return NYX_XPATH_USERS.length; - }} - }} - m = predicate.match(/^"([^"]*)"\s*$/); - if (m) {{ - const literal = m[1]; - return NYX_XPATH_USERS.filter((u) => u === literal).length; - }} - if (/^concat\(/i.test(predicate)) {{ - const parts = [...predicate.matchAll(/'([^']*)'/g)].map((x) => x[1]); - let joined = parts.filter((p) => p !== ',"').join(''); - joined = joined.split(",\"'\",").join("'"); - return NYX_XPATH_USERS.filter((u) => u === joined).length; - }} - return NYX_XPATH_USERS.length; -}} - function nyxXpathViaFixture(payload) {{ // Phase 07 tier-(a): require the fixture and call its // `{entry_name}` so the real `xpath.select` (or other XPath evaluator - // the fixture chooses) runs against the staged corpus document. - // Returns the node count, or `null` when the require / lookup / call - // fails (e.g. the `xpath` npm package is not installed on the host) - // so the caller can fall back to the inline matcher. + // the fixture chooses) runs against the staged corpus document. A + // missing `xpath` host install is the only structural reason the + // require fails; in that case we emit the conventional + // `NYX_IMPORT_ERROR:` stderr marker plus `process.exit(77)` so the + // runner maps the outcome to `RunError::BuildFailed` and the e2e + // SKIP branch fires. let _entry; try {{ _entry = require('./{entry_stem}'); }} catch (e) {{ - return null; + process.stderr.write('NYX_IMPORT_ERROR: ' + e.message + '\n'); + process.exit(77); }} const fn = _entry && (typeof _entry === 'function' ? _entry : _entry['{entry_name}']); - if (typeof fn !== 'function') return null; + if (typeof fn !== 'function') {{ + throw new Error("Phase 07 XPath harness: entry function '{entry_name}' not found in fixture module './{entry_stem}'"); + }} let result; try {{ result = fn(payload); @@ -1321,23 +1289,21 @@ function nyxXpathProbe(expr, nodesReturned) {{ const payload = process.env.NYX_PAYLOAD || ''; const expr = "//user[@name='" + payload + "']"; -let nodes = nyxXpathViaFixture(payload); -if (nodes === null) {{ - nodes = nyxXpathSelect(expr); -}} +const nodes = nyxXpathViaFixture(payload); +console.log('__NYX_XPATH_TIER_A__'); nyxXpathProbe(expr, nodes); console.log('__NYX_SINK_HIT__'); console.log(JSON.stringify({{ expr: expr, nodes_returned: nodes }})); "# ); - let mut extra_files = vec![(corpus_filename.to_owned(), corpus_xml.to_owned())]; - if uses_real_xpath { - extra_files.push(("package.json".to_owned(), package_json_xpath())); - extra_files.push(( + let extra_files = vec![ + (corpus_filename.to_owned(), corpus_xml.to_owned()), + ("package.json".to_owned(), package_json_xpath()), + ( "package-lock.json".to_owned(), package_lock_skeleton("nyx-harness-xpath"), - )); - } + ), + ]; HarnessSource { source: body, filename: "harness.js".to_owned(), @@ -2857,7 +2823,7 @@ mod tests { } #[test] - fn emit_xpath_harness_drives_fixture_through_real_xpath_when_imported() { + fn emit_xpath_harness_routes_through_fixture_require() { let dir = std::env::temp_dir().join("nyx_phase07_js_test_drive_fixture"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); @@ -2891,34 +2857,34 @@ mod tests { h.source ); assert!( - h.source.contains("nodes = nyxXpathSelect(expr);"), - "tier-(a) harness must preserve the inline matcher as a fallback: {}", + h.source.contains("__NYX_XPATH_TIER_A__"), + "harness must emit the tier-(a) stdout marker after the real xpath call: {}", h.source ); assert!( h.extra_files .iter() .any(|(p, c)| p == "package.json" && c.contains("\"xpath\"")), - "tier-(a) harness must stage a package.json with the xpath dep", + "harness must always stage a package.json with the xpath dep", ); assert!( h.extra_files .iter() .any(|(p, c)| p == "package.json" && c.contains("@xmldom/xmldom")), - "tier-(a) harness must stage a package.json with the xmldom dep", + "harness must always stage a package.json with the xmldom dep", ); assert!( h.extra_files .iter() .any(|(p, _)| p == "package-lock.json"), - "tier-(a) harness must stage a package-lock.json", + "harness must always stage a package-lock.json", ); let _ = std::fs::remove_dir_all(&dir); } #[test] - fn emit_xpath_harness_falls_back_to_inline_matcher_without_xpath_require() { - let dir = std::env::temp_dir().join("nyx_phase07_js_test_no_xpath_require"); + fn emit_xpath_harness_drops_inline_matcher_fallback() { + let dir = std::env::temp_dir().join("nyx_phase07_js_test_no_inline_matcher"); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); let entry = dir.join("vuln.js"); @@ -2929,14 +2895,28 @@ mod tests { .unwrap(); let h = emit_xpath_harness(&make_xpath_spec(entry.to_str().unwrap(), "run")); assert!( - !h.extra_files.iter().any(|(p, _)| p == "package.json"), - "fallback path must not stage a package.json (xpath dep would be unused)", + !h.source.contains("nyxXpathSelect"), + "harness must not carry the inline `nyxXpathSelect` matcher; tier-(a) is the only path", ); assert!( - !h.extra_files + !h.source.contains("NYX_XPATH_USERS"), + "harness must not carry the inline `NYX_XPATH_USERS` table; tier-(a) is the only path", + ); + assert!( + h.source.contains("NYX_IMPORT_ERROR:") && h.source.contains("process.exit(77)"), + "harness must emit `NYX_IMPORT_ERROR:` stderr marker + `process.exit(77)` on require failure: {}", + h.source + ); + assert!( + h.source.contains("__NYX_XPATH_TIER_A__"), + "harness must emit the tier-(a) stdout marker: {}", + h.source + ); + assert!( + h.extra_files .iter() - .any(|(p, _)| p == "package-lock.json"), - "fallback path must not stage a package-lock.json", + .any(|(p, _)| p == "package.json"), + "harness must always stage a package.json (real-xpath dep is required, no synthetic-only path)", ); let _ = std::fs::remove_dir_all(&dir); } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 13634df9..32f2ede6 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1230,77 +1230,6 @@ pub fn emit_xpath_harness(spec: &HarnessSpec) -> HarnessSource { // Nyx dynamic harness — XPATH_INJECTION DOMXPath::query (Phase 07 / Track J.5). {shim} -// Synthetic in-process XPath evaluator over the canonical staged -// document — counts nodes that satisfy the `[@name='…']` -// predicate the host code synthesised from the payload. Real -// `DOMXPath::query` is not invoked (the harness ignores `_spec` and -// inlines the evaluator); the differential rule still holds because -// the vuln payload's `' or '1'='1` tail rewraps the selector into a -// match-everything shape. -$NYX_XPATH_USERS = ['alice', 'bob', 'carol']; - -function _nyx_xpath_select($expr, array $users): int {{ - // Recognise the canonical `//user[@name='']` shape the - // synthetic harness emits. Anything else falls through to "no - // match" so a malformed expression cannot accidentally confirm. - $needle = "//user[@name="; - if (strncmp($expr, $needle, strlen($needle)) !== 0) {{ - return 0; - }} - $rest = substr($expr, strlen($needle)); - if (!str_ends_with($rest, ']')) {{ - return 0; - }} - $predicate = substr($rest, 0, strlen($rest) - 1); - if (preg_match("/^'([^']*)'(.*)\$/", $predicate, $m)) {{ - // `name='alice'` → exact-match against the literal - // `name='alice' or '1'='1'` → OR-tail breakouts; presence of - // ` or ` after the closing quote means the selector is now - // tautological → every user matches. - $literal = $m[1]; - $tail = trim($m[2]); - if ($tail === '' || $tail === ']') {{ - $count = 0; - foreach ($users as $u) {{ - if ($u === $literal) $count++; - }} - return $count; - }} - if (preg_match("/^or\\s+/i", $tail)) {{ - return count($users); - }} - }} - if (preg_match('/^"([^"]*)"\\s*$/', $predicate, $m)) {{ - $literal = $m[1]; - $count = 0; - foreach ($users as $u) {{ - if ($u === $literal) $count++; - }} - return $count; - }} - if (preg_match("/^concat\\(/i", $predicate)) {{ - // `concat('a',\"'\",'b')` benign-escape path: extract the - // joined literal and match exactly once. - if (preg_match_all("/'([^']*)'/", $predicate, $parts)) {{ - $joined = ''; - foreach ($parts[1] as $p) {{ - if ($p === ',"') continue; - $joined .= $p; - }} - // Normalise embedded single-quote literals back to the - // raw character so a `concat`-quoted username collapses - // to the same literal the user typed. - $joined = str_replace(",\"'\",", "'", $joined); - $count = 0; - foreach ($users as $u) {{ - if ($u === $joined) $count++; - }} - return $count; - }} - }} - return count($users); -}} - function _nyx_xpath_probe(string $expr, int $nodes_returned): void {{ $p = getenv('NYX_PROBE_PATH'); if ($p === false || $p === '') return; @@ -1315,23 +1244,34 @@ function _nyx_xpath_probe(string $expr, int $nodes_returned): void {{ @file_put_contents($p, json_encode($rec) . "\n", FILE_APPEND); }} -function _nyx_xpath_via_fixture(string $payload, string $entry_basename, string $entry_name): ?int {{ +function _nyx_xpath_via_fixture(string $payload, string $entry_basename, string $entry_name): int {{ // Phase 07 tier-(a): require the fixture file and call its // `$entry_name` function so the real `DOMXPath::query` runs - // against the staged corpus document. Returns the result-set - // length, or `null` when the require / call fails so the caller - // can fall back to the inline matcher. + // against the staged corpus document. A missing `ext-dom` / + // `ext-xml` host install or an inaccessible fixture file is the + // only structural reason this fails; in that case we emit the + // conventional `NYX_IMPORT_ERROR:` stderr marker plus `exit(77)` + // so the runner maps the outcome to `RunError::BuildFailed` and + // the e2e SKIP branch fires. + if (!class_exists('DOMDocument') || !class_exists('DOMXPath')) {{ + fwrite(STDERR, "NYX_IMPORT_ERROR: ext-dom / ext-xml not loaded\n"); + exit(77); + }} $candidate = __DIR__ . DIRECTORY_SEPARATOR . $entry_basename; if (!is_file($candidate)) {{ - return null; + fwrite(STDERR, "NYX_IMPORT_ERROR: fixture file not found at $candidate\n"); + exit(77); }} try {{ require_once $candidate; - }} catch (\Throwable $_) {{ - return null; + }} catch (\Throwable $_e) {{ + fwrite(STDERR, "NYX_IMPORT_ERROR: " . $_e->getMessage() . "\n"); + exit(77); }} if (!function_exists($entry_name)) {{ - return null; + throw new \RuntimeException( + "Phase 07 XPath harness: entry function '$entry_name' not found in fixture '$entry_basename'" + ); }} try {{ $result = $entry_name($payload); @@ -1346,15 +1286,13 @@ function _nyx_xpath_via_fixture(string $payload, string $entry_basename, string if (is_array($result)) {{ return count($result); }} - return null; + return 0; }} $payload = (string) (getenv('NYX_PAYLOAD') ?: ''); $expr = "//user[@name='" . $payload . "']"; $nodes = _nyx_xpath_via_fixture($payload, "{entry_basename}", "{entry_name}"); -if ($nodes === null) {{ - $nodes = _nyx_xpath_select($expr, $NYX_XPATH_USERS); -}} +echo "__NYX_XPATH_TIER_A__\n"; _nyx_xpath_probe($expr, $nodes); echo "__NYX_SINK_HIT__\n"; echo json_encode(['expr' => $expr, 'nodes_returned' => $nodes]) . "\n"; @@ -2494,9 +2432,35 @@ mod tests { "PHP XPath harness must check the result against DOMNodeList", ); assert!( + h.source.contains("__NYX_XPATH_TIER_A__"), + "PHP XPath harness must emit the tier-(a) stdout marker after the real DOMXPath call: {}", + h.source + ); + } + + #[test] + fn emit_xpath_harness_drops_inline_matcher_fallback() { + let h = emit_xpath_harness(&make_xpath_spec( + "tests/dynamic_fixtures/xpath_injection/php/vuln.php", + "run", + )); + assert!( + !h.source.contains("_nyx_xpath_select"), + "PHP XPath harness must not carry the inline `_nyx_xpath_select` matcher; tier-(a) is the only path", + ); + assert!( + !h.source.contains("NYX_XPATH_USERS"), + "PHP XPath harness must not carry the inline `NYX_XPATH_USERS` table; tier-(a) is the only path", + ); + assert!( + h.source.contains("NYX_IMPORT_ERROR:") && h.source.contains("exit(77)"), + "PHP XPath harness must emit `NYX_IMPORT_ERROR:` stderr marker + `exit(77)` on require / ext failure: {}", + h.source + ); + assert!( + h.source.contains("__NYX_XPATH_TIER_A__"), + "PHP XPath harness must emit the tier-(a) stdout marker: {}", h.source - .contains("$nodes = _nyx_xpath_select($expr, $NYX_XPATH_USERS);"), - "PHP XPath harness must keep the inline matcher as a fallback", ); } diff --git a/tests/xpath_corpus.rs b/tests/xpath_corpus.rs index 40e16ccb..68e3ee40 100644 --- a/tests/xpath_corpus.rs +++ b/tests/xpath_corpus.rs @@ -403,8 +403,11 @@ fn staged_corpus_carries_three_users() { // verdict path is deterministic without spawning a real XPath // engine (`stubs_required: vec![]`). // -// JavaScript is skipped: the synthetic harness's `require('xpath')` -// import resolves only when the workdir has the package installed. +// Each lang asserts the tier-(a) stdout marker so a regression that +// silently falls back to the inline matcher (now deleted) trips the +// test; on hosts without the real engine installed the harness exits +// 77 with `NYX_IMPORT_ERROR:` and `is_runtime_import_error` maps it to +// `RunError::BuildFailed` (SKIP). mod e2e_phase_07 { use crate::common::fixture_harness::FIXTURE_LOCK; @@ -533,6 +536,17 @@ mod e2e_phase_07 { .as_ref() .expect("Confirmed run must carry a DifferentialOutcome"); assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + let tier_a_marker = b"__NYX_XPATH_TIER_A__"; + let saw_tier_a = outcome.attempts.iter().any(|a| { + a.outcome + .stdout + .windows(tier_a_marker.len()) + .any(|w| w == tier_a_marker) + }); + assert!( + saw_tier_a, + "Java XPath vuln must reach the tier-(a) real-javax.xml.xpath path (stdout marker `__NYX_XPATH_TIER_A__`); the inline `nyxXpathSelect` fallback was removed and the harness now SKIPs via NYX_IMPORT_ERROR + System.exit(77) when the reflective lookup fails", + ); } #[test] @@ -576,6 +590,17 @@ mod e2e_phase_07 { .as_ref() .expect("Confirmed run must carry a DifferentialOutcome"); assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + let tier_a_marker = b"__NYX_XPATH_TIER_A__"; + let saw_tier_a = outcome.attempts.iter().any(|a| { + a.outcome + .stdout + .windows(tier_a_marker.len()) + .any(|w| w == tier_a_marker) + }); + assert!( + saw_tier_a, + "PHP XPath vuln must reach the tier-(a) real-DOMXPath path (stdout marker `__NYX_XPATH_TIER_A__`); the inline `_nyx_xpath_select` fallback was removed and the harness now SKIPs via NYX_IMPORT_ERROR + exit 77 when ext-dom/ext-xml is unavailable", + ); } #[test] @@ -592,5 +617,16 @@ mod e2e_phase_07 { .as_ref() .expect("Confirmed run must carry a DifferentialOutcome"); assert_eq!(diff.verdict, DifferentialVerdict::Confirmed); + let tier_a_marker = b"__NYX_XPATH_TIER_A__"; + let saw_tier_a = outcome.attempts.iter().any(|a| { + a.outcome + .stdout + .windows(tier_a_marker.len()) + .any(|w| w == tier_a_marker) + }); + assert!( + saw_tier_a, + "JavaScript XPath vuln must reach the tier-(a) real-xpath path (stdout marker `__NYX_XPATH_TIER_A__`); the inline `nyxXpathSelect` fallback was removed and the harness now SKIPs via NYX_IMPORT_ERROR + exit 77 when the `xpath` npm package is unavailable", + ); } }