From 78023ccf385da77db0ae41f0397da5875cf7aaf6 Mon Sep 17 00:00:00 2001 From: pitboss Date: Mon, 18 May 2026 13:46:43 -0500 Subject: [PATCH] =?UTF-8?q?[pitboss]=20phase=2014:=20Track=20L.12=20?= =?UTF-8?q?=E2=80=94=20Spring=20/=20Quarkus=20/=20Micronaut=20/=20Jakarta?= =?UTF-8?q?=20Servlet=20adapters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/dynamic/build_sandbox.rs | 1 + src/dynamic/environment.rs | 1 + .../framework/adapters/java_micronaut.rs | 171 +++++++ .../framework/adapters/java_quarkus.rs | 175 +++++++ src/dynamic/framework/adapters/java_routes.rs | 455 ++++++++++++++++++ .../framework/adapters/java_servlet.rs | 175 +++++++ src/dynamic/framework/adapters/java_spring.rs | 236 +++++++++ src/dynamic/framework/adapters/mod.rs | 9 + src/dynamic/framework/mod.rs | 41 +- src/dynamic/framework/registry.rs | 4 + src/dynamic/harness.rs | 2 + src/dynamic/lang/c.rs | 1 + src/dynamic/lang/cpp.rs | 1 + src/dynamic/lang/go.rs | 1 + src/dynamic/lang/java.rs | 68 ++- src/dynamic/lang/javascript.rs | 1 + src/dynamic/lang/js_shared.rs | 1 + src/dynamic/lang/php.rs | 1 + src/dynamic/lang/python.rs | 1 + src/dynamic/lang/ruby.rs | 1 + src/dynamic/lang/rust.rs | 1 + src/dynamic/lang/typescript.rs | 1 + src/dynamic/repro.rs | 1 + src/dynamic/spec.rs | 52 ++ src/dynamic/telemetry.rs | 1 + tests/common/fixture_harness.rs | 2 + tests/deserialize_corpus.rs | 2 + .../java/micronaut_route/Benign.java | 30 ++ .../java/micronaut_route/Controller.java | 17 + .../java/micronaut_route/Get.java | 14 + .../java/micronaut_route/Vuln.java | 32 ++ .../java/micronaut_route/pom.xml | 18 + tests/env_capture_flask.rs | 1 + tests/header_injection_corpus.rs | 2 + tests/java_fixtures.rs | 1 + tests/java_frameworks_corpus.rs | 189 ++++++++ tests/ldap_corpus.rs | 2 + tests/open_redirect_corpus.rs | 2 + tests/oracle_sink_crash.rs | 1 + tests/prototype_pollution_corpus.rs | 2 + tests/repro_determinism.rs | 6 + tests/repro_fixture_bundles.rs | 1 + tests/repro_hermetic.rs | 1 + tests/ssti_corpus.rs | 2 + tests/telemetry_schema.rs | 1 + tests/xpath_corpus.rs | 2 + tests/xxe_corpus.rs | 2 + 47 files changed, 1711 insertions(+), 21 deletions(-) create mode 100644 src/dynamic/framework/adapters/java_micronaut.rs create mode 100644 src/dynamic/framework/adapters/java_quarkus.rs create mode 100644 src/dynamic/framework/adapters/java_routes.rs create mode 100644 src/dynamic/framework/adapters/java_servlet.rs create mode 100644 src/dynamic/framework/adapters/java_spring.rs create mode 100644 tests/dynamic_fixtures/java/micronaut_route/Benign.java create mode 100644 tests/dynamic_fixtures/java/micronaut_route/Controller.java create mode 100644 tests/dynamic_fixtures/java/micronaut_route/Get.java create mode 100644 tests/dynamic_fixtures/java/micronaut_route/Vuln.java create mode 100644 tests/dynamic_fixtures/java/micronaut_route/pom.xml create mode 100644 tests/java_frameworks_corpus.rs diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 8d19878e..1f49e941 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -1643,6 +1643,7 @@ mod tests { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/environment.rs b/src/dynamic/environment.rs index 33239423..46ec7474 100644 --- a/src/dynamic/environment.rs +++ b/src/dynamic/environment.rs @@ -1177,6 +1177,7 @@ mod tests { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/framework/adapters/java_micronaut.rs b/src/dynamic/framework/adapters/java_micronaut.rs new file mode 100644 index 00000000..5ea787c7 --- /dev/null +++ b/src/dynamic/framework/adapters/java_micronaut.rs @@ -0,0 +1,171 @@ +//! Java Micronaut [`super::super::FrameworkAdapter`] (Phase 14 — Track L.12). +//! +//! Recognises Micronaut `@Controller("/path")` on a class plus a +//! handler method annotated with `@Get("/sub")` / `@Post` / `@Put` / +//! `@Delete` / `@Patch` / `@Head` / `@Options` (mixed-case, distinct +//! from JAX-RS all-caps verbs). Fires only when the source carries +//! a Micronaut import stanza so a Spring `@Controller` + Spring +//! `@GetMapping` file does not collide with this adapter. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::java_routes::{ + annotation_string_arg, bind_java_params, find_class_with_method, iter_annotations, + join_route_path, method_formal_types, source_imports_micronaut, +}; + +pub struct JavaMicronautAdapter; + +const ADAPTER_NAME: &str = "java-micronaut"; + +fn verb_for(name: &str) -> Option { + match name { + "Get" => Some(HttpMethod::GET), + "Post" => Some(HttpMethod::POST), + "Put" => Some(HttpMethod::PUT), + "Delete" => Some(HttpMethod::DELETE), + "Patch" => Some(HttpMethod::PATCH), + "Head" => Some(HttpMethod::HEAD), + "Options" => Some(HttpMethod::OPTIONS), + _ => None, + } +} + +fn class_path_prefix(class: Node<'_>, bytes: &[u8]) -> Option { + let mut hit: Option = None; + iter_annotations(class, bytes, |ann, name| { + if name == "Controller" { + hit = Some(annotation_string_arg(ann, bytes).unwrap_or_default()); + } + }); + hit +} + +fn method_verb_and_path( + method: Node<'_>, + bytes: &[u8], +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + iter_annotations(method, bytes, |ann, name| { + if hit.is_some() { + return; + } + if let Some(v) = verb_for(name) { + let path = annotation_string_arg(ann, bytes).unwrap_or_default(); + hit = Some((v, path)); + } + }); + hit +} + +impl FrameworkAdapter for JavaMicronautAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Java + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_micronaut(file_bytes) { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + let class_prefix = class_path_prefix(class, file_bytes)?; + let (http_method, method_path) = method_verb_and_path(method, file_bytes)?; + let path = join_route_path(&class_prefix, &method_path); + let formals = method_formal_types(method, file_bytes); + let request_params = bind_java_params(&formals, &path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "java".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_controller_plus_get() { + let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/api\")\npublic class V {\n @Get(\"/{id}\")\n public String show(String id) { return id; }\n}\n"; + let tree = parse(src); + let binding = JavaMicronautAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "java-micronaut"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/api/{id}"); + let id_binding = binding + .request_params + .iter() + .find(|p| p.name == "id") + .unwrap(); + assert!(matches!(id_binding.source, ParamSource::PathSegment(_))); + } + + #[test] + fn fires_on_post_with_empty_prefix() { + let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Post;\n@Controller\npublic class V {\n @Post(\"/save\")\n public String save(String body) { return body; }\n}\n"; + let tree = parse(src); + let binding = JavaMicronautAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/save"); + } + + #[test] + fn skips_non_micronaut_file() { + let src: &[u8] = b"@Controller\npublic class C {\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + assert!(JavaMicronautAdapter + .detect(&summary("x"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_method_without_micronaut_verb() { + let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\n@Controller(\"/api\")\npublic class V {\n public String helper() { return \"\"; }\n}\n"; + let tree = parse(src); + assert!(JavaMicronautAdapter + .detect(&summary("helper"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/java_quarkus.rs b/src/dynamic/framework/adapters/java_quarkus.rs new file mode 100644 index 00000000..a2b2e779 --- /dev/null +++ b/src/dynamic/framework/adapters/java_quarkus.rs @@ -0,0 +1,175 @@ +//! Java Quarkus / Jakarta REST [`super::super::FrameworkAdapter`] +//! (Phase 14 — Track L.12). +//! +//! Recognises `@Path("/path")` on a class plus a handler method +//! annotated with `@GET` / `@POST` / `@PUT` / `@DELETE` / `@PATCH` / +//! `@HEAD` / `@OPTIONS` (all-caps JAX-RS verb annotations, distinct +//! from Micronaut's mixed-case `@Get` / `@Post`). Method-level +//! `@Path("/sub")` is concatenated with the class-level prefix. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::java_routes::{ + annotation_string_arg, bind_java_params, find_class_with_method, iter_annotations, + join_route_path, method_formal_types, source_imports_quarkus, +}; + +pub struct JavaQuarkusAdapter; + +const ADAPTER_NAME: &str = "java-quarkus"; + +fn verb_for(name: &str) -> Option { + match name { + "GET" => Some(HttpMethod::GET), + "POST" => Some(HttpMethod::POST), + "PUT" => Some(HttpMethod::PUT), + "DELETE" => Some(HttpMethod::DELETE), + "PATCH" => Some(HttpMethod::PATCH), + "HEAD" => Some(HttpMethod::HEAD), + "OPTIONS" => Some(HttpMethod::OPTIONS), + _ => None, + } +} + +fn class_path_prefix(class: Node<'_>, bytes: &[u8]) -> String { + let mut prefix = String::new(); + iter_annotations(class, bytes, |ann, name| { + if name == "Path" { + if let Some(p) = annotation_string_arg(ann, bytes) { + prefix = p; + } + } + }); + prefix +} + +fn method_verb_and_path( + method: Node<'_>, + bytes: &[u8], +) -> Option<(HttpMethod, String)> { + let mut verb: Option = None; + let mut path = String::new(); + iter_annotations(method, bytes, |ann, name| { + if let Some(v) = verb_for(name) { + verb = Some(v); + } + if name == "Path" { + if let Some(p) = annotation_string_arg(ann, bytes) { + path = p; + } + } + }); + Some((verb?, path)) +} + +impl FrameworkAdapter for JavaQuarkusAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Java + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_quarkus(file_bytes) { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + let (http_method, method_path) = method_verb_and_path(method, file_bytes)?; + let class_prefix = class_path_prefix(class, file_bytes); + let path = join_route_path(&class_prefix, &method_path); + let formals = method_formal_types(method, file_bytes); + let request_params = bind_java_params(&formals, &path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "java".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_class_path_plus_method_get() { + let src: &[u8] = b"import jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\n@Path(\"/api\")\npublic class V {\n @GET\n @Path(\"/{id}\")\n public String show(String id) { return id; }\n}\n"; + let tree = parse(src); + let binding = JavaQuarkusAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "java-quarkus"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/api/{id}"); + let id_binding = binding + .request_params + .iter() + .find(|p| p.name == "id") + .unwrap(); + assert!(matches!(id_binding.source, ParamSource::PathSegment(_))); + } + + #[test] + fn fires_on_post_without_class_prefix() { + let src: &[u8] = b"import io.quarkus.runtime.Quarkus;\nimport jakarta.ws.rs.POST;\n@Path(\"/save\")\npublic class V {\n @POST\n public String save(String body) { return body; }\n}\n"; + let tree = parse(src); + let binding = JavaQuarkusAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/save"); + } + + #[test] + fn skips_non_quarkus_file() { + let src: &[u8] = b"@RestController\npublic class C {\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + assert!(JavaQuarkusAdapter + .detect(&summary("x"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_method_without_verb_annotation() { + let src: &[u8] = b"import jakarta.ws.rs.Path;\n@Path(\"/api\")\npublic class V {\n public String helper() { return \"\"; }\n}\n"; + let tree = parse(src); + assert!(JavaQuarkusAdapter + .detect(&summary("helper"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/java_routes.rs b/src/dynamic/framework/adapters/java_routes.rs new file mode 100644 index 00000000..6eda6ae6 --- /dev/null +++ b/src/dynamic/framework/adapters/java_routes.rs @@ -0,0 +1,455 @@ +//! Shared Java-route adapter helpers (Phase 14 — Track L.12). +//! +//! The Spring / Quarkus / Micronaut / Servlet adapters all share the +//! same handful of tree-sitter helpers: locate a `class_declaration` +//! containing a `method_declaration` whose name matches the target, +//! walk the class- and method-level annotation lists, pull a string +//! argument from an annotation, classify the path placeholders, and +//! bind formals to request slots. Centralising the helpers keeps the +//! four adapters terse and makes the placeholder-binding semantics +//! identical across frameworks. + +use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource}; +use tree_sitter::Node; + +/// True when `bytes` carries any of the well-known Spring import +/// stanzas or the bare `@RestController` / `@RequestMapping` / +/// `@GetMapping` / `@PostMapping` annotations (the synthetic-import +/// fixture path used by the Phase 14 corpus). +pub fn source_imports_spring(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"org.springframework", + b"@RestController", + b"@Controller(", + b"@Controller\n", + b"@Controller\r", + b"@RequestMapping", + b"@GetMapping", + b"@PostMapping", + b"@PutMapping", + b"@PatchMapping", + b"@DeleteMapping", + ], + ) +} + +/// True when `bytes` carries a Quarkus or JAX-RS / Jakarta REST +/// stanza. Distinct from `source_imports_spring` so the Spring +/// adapter does not collide on a Quarkus file that happens to use +/// the bare `@Path` annotation. +pub fn source_imports_quarkus(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"io.quarkus", + b"jakarta.ws.rs", + b"javax.ws.rs", + b"@QuarkusTest", + b"@Path(", + ], + ) +} + +/// True when `bytes` carries a Micronaut import stanza. Micronaut +/// reuses `@Controller` as a class-level marker but pairs it with +/// `@Get` / `@Post` / `@Put` / `@Delete` (mixed-case, distinct from +/// the all-caps JAX-RS verb annotations Quarkus picks up). +pub fn source_imports_micronaut(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"io.micronaut", + b"@MicronautTest", + b"micronaut.http.annotation", + ], + ) +} + +/// True when `bytes` carries any of the well-known Java Servlet API +/// import stanzas or a class extending `HttpServlet`. The bare +/// `HttpServletRequest` / `HttpServletResponse` stub-class names also +/// fire so the Phase 14 default-package fixture path lights up the +/// adapter without a Jakarta servlet jar. +pub fn source_imports_servlet(bytes: &[u8]) -> bool { + contains_any( + bytes, + &[ + b"javax.servlet", + b"jakarta.servlet", + b"HttpServletRequest", + b"HttpServletResponse", + b"extends HttpServlet", + ], + ) +} + +fn contains_any(haystack: &[u8], needles: &[&[u8]]) -> bool { + needles + .iter() + .any(|n| haystack.windows(n.len()).any(|w| w == *n)) +} + +/// Locate the (class_decl, method_decl) pair whose method's name +/// equals `target`. Returns the outermost matching class so the +/// caller can read class-level annotations (route prefix, auth +/// markers) without re-walking. +pub fn find_class_with_method<'a>( + root: Node<'a>, + bytes: &[u8], + target: &str, +) -> Option<(Node<'a>, Node<'a>)> { + let mut hit: Option<(Node<'a>, Node<'a>)> = None; + walk(root, bytes, target, &mut hit); + hit +} + +fn walk<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + out: &mut Option<(Node<'a>, Node<'a>)>, +) { + if out.is_some() { + return; + } + if node.kind() == "class_declaration" { + if let Some(body) = node + .child_by_field_name("body") + .or_else(|| named_child_of_kind(node, "class_body")) + { + let mut cur = body.walk(); + for member in body.children(&mut cur) { + if member.kind() != "method_declaration" { + continue; + } + if let Some(name) = member + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + { + if name == target { + *out = Some((node, member)); + return; + } + } + } + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk(child, bytes, target, out); + } +} + +fn named_child_of_kind<'a>(node: Node<'a>, kind: &str) -> Option> { + let mut cur = node.walk(); + node.named_children(&mut cur).find(|c| c.kind() == kind) +} + +/// True when `node` is a `marker_annotation` (`@GET`) or `annotation` +/// (`@Path("/x")`). +pub fn is_annotation(node: Node<'_>) -> bool { + matches!(node.kind(), "annotation" | "marker_annotation") +} + +/// Read the leaf annotation name (`@a.b.GetMapping` → `"GetMapping"`). +pub fn annotation_leaf<'a>(ann: Node<'a>, bytes: &'a [u8]) -> Option<&'a str> { + let name = ann.child_by_field_name("name")?.utf8_text(bytes).ok()?; + Some(name.rsplit('.').next().unwrap_or(name)) +} + +/// Extract the first quoted string argument from an annotation node, +/// supporting both positional (`@Path("/x")`) and `value="…"` / +/// `path="…"` keyword forms. +pub fn annotation_string_arg(ann: Node<'_>, bytes: &[u8]) -> Option { + let args = ann.child_by_field_name("arguments")?; + let raw = args.utf8_text(bytes).ok()?; + // Try `value = "…"` / `path = "…"` first so the keyword form is + // not accidentally captured by the bare-string scan. + for key in ["value", "path"] { + if let Some(start) = raw.find(&format!("{key} = ")).or_else(|| raw.find(&format!("{key}="))) { + let after = &raw[start..]; + if let Some(open) = after.find('"') { + let rest = &after[open + 1..]; + if let Some(close) = rest.find('"') { + return Some(rest[..close].to_owned()); + } + } + } + } + let open = raw.find('"')? + 1; + let close = raw[open..].find('"')? + open; + Some(raw[open..close].to_owned()) +} + +/// Iterate annotations attached to a `class_declaration` or +/// `method_declaration` node via its `modifiers` child. +pub fn iter_annotations<'a, F>(node: Node<'a>, bytes: &'a [u8], mut visit: F) +where + F: FnMut(Node<'a>, &str), +{ + let Some(modifiers) = named_child_of_kind(node, "modifiers") else { + return; + }; + let mut cur = modifiers.walk(); + for ann in modifiers.children(&mut cur) { + if !is_annotation(ann) { + continue; + } + if let Some(name) = annotation_leaf(ann, bytes) { + visit(ann, name); + } + } +} + +/// True when the class declaration extends a class whose simple name +/// matches `target`. The match strips package qualifiers so +/// `jakarta.servlet.http.HttpServlet` and bare `HttpServlet` both +/// trip the predicate. +pub fn class_extends(class: Node<'_>, bytes: &[u8], target: &str) -> bool { + let Some(superclass) = class.child_by_field_name("superclass") else { + return false; + }; + let Ok(text) = superclass.utf8_text(bytes) else { + return false; + }; + let cleaned = text.trim().trim_start_matches("extends ").trim(); + let leaf = cleaned.rsplit('.').next().unwrap_or(cleaned); + leaf.split_whitespace() + .next() + .unwrap_or(leaf) + .trim_end_matches('<') + == target +} + +/// Parse `method = RequestMethod.` (or array form) from a +/// `@RequestMapping(...)` annotation's raw arguments text. +pub fn request_method_from_args(ann: Node<'_>, bytes: &[u8]) -> Option { + let args = ann.child_by_field_name("arguments")?; + let raw = args.utf8_text(bytes).ok()?; + for verb in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] { + if raw.contains(&format!("RequestMethod.{verb}")) { + return HttpMethod::from_ident(verb); + } + } + None +} + +/// Extract `(type_simple_name, formal_name)` pairs from a +/// `method_declaration` node. The simple type lets adapters +/// recognise framework-implicit slots (`HttpServletRequest` / +/// `HttpServletResponse`) and route the remaining formals to query / +/// body params. +pub fn method_formal_types(method: Node<'_>, bytes: &[u8]) -> Vec<(String, String)> { + let mut out = Vec::new(); + let Some(params) = method.child_by_field_name("parameters") else { + return out; + }; + let mut cur = params.walk(); + for fp in params.named_children(&mut cur) { + if fp.kind() != "formal_parameter" && fp.kind() != "spread_parameter" { + continue; + } + let ty = fp + .child_by_field_name("type") + .and_then(|t| t.utf8_text(bytes).ok()) + .unwrap_or("") + .trim(); + let name = fp + .child_by_field_name("name") + .and_then(|n| n.utf8_text(bytes).ok()) + .unwrap_or("") + .trim(); + if name.is_empty() { + continue; + } + let ty_leaf = ty.rsplit('.').next().unwrap_or(ty); + let ty_simple = ty_leaf + .split('<') + .next() + .unwrap_or(ty_leaf) + .trim() + .to_owned(); + out.push((ty_simple, name.to_owned())); + } + out +} + +/// Extract placeholder names from a route path template. +/// +/// Supports two placeholder syntaxes: +/// - JAX-RS / Spring / Micronaut: `/users/{id}` → `id`, +/// `/users/{id:[0-9]+}` → `id`. +/// - Servlet-mapping `*` wildcards: ignored (no name to bind). +pub fn extract_path_placeholders(path: &str) -> Vec { + let mut out: Vec = Vec::new(); + let bytes = path.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { + let inner = &path[i + 1..i + 1 + end]; + let name = inner.split(':').next().unwrap_or(inner).trim(); + if !name.is_empty() && !out.iter().any(|n| n == name) { + out.push(name.to_owned()); + } + i += end + 2; + continue; + } + } + i += 1; + } + out +} + +/// Bind formals to request slots given a route path template. +/// +/// `HttpServletRequest` / `HttpServletResponse` / `ServletRequest` / +/// `ServletResponse` / `HttpRequest` / `HttpResponse` go to +/// [`ParamSource::Implicit`]. A formal whose name matches a +/// placeholder becomes a [`ParamSource::PathSegment`]; everything +/// else falls back to [`ParamSource::QueryParam`]. +pub fn bind_java_params(formals: &[(String, String)], path: &str) -> Vec { + let placeholders = extract_path_placeholders(path); + formals + .iter() + .enumerate() + .map(|(idx, (ty, name))| { + let source = if is_implicit_type(ty) { + ParamSource::Implicit + } else if placeholders.iter().any(|p| p == name) { + ParamSource::PathSegment(name.clone()) + } else { + ParamSource::QueryParam(name.clone()) + }; + ParamBinding { + index: idx, + name: name.clone(), + source, + } + }) + .collect() +} + +fn is_implicit_type(ty: &str) -> bool { + matches!( + ty, + "HttpServletRequest" + | "HttpServletResponse" + | "ServletRequest" + | "ServletResponse" + | "HttpRequest" + | "HttpResponse" + | "MultiValueMap" + | "Model" + ) +} + +/// Concatenate a class-level path prefix and a method-level path +/// suffix. Strips a trailing slash from the prefix and a leading +/// slash from the suffix to avoid `/api//x`-style joins. +pub fn join_route_path(class_path: &str, method_path: &str) -> String { + if class_path.is_empty() { + return method_path.to_owned(); + } + if method_path.is_empty() { + return class_path.to_owned(); + } + format!( + "{}/{}", + class_path.trim_end_matches('/'), + method_path.trim_start_matches('/') + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + #[test] + fn finds_class_and_method() { + let src: &[u8] = b"public class V { public String run(String x) { return x; } }\n"; + let tree = parse(src); + let (class, method) = find_class_with_method(tree.root_node(), src, "run").unwrap(); + assert_eq!(class.kind(), "class_declaration"); + assert_eq!(method.kind(), "method_declaration"); + } + + #[test] + fn extracts_brace_placeholders() { + assert_eq!(extract_path_placeholders("/users/{id}"), vec!["id"]); + assert_eq!( + extract_path_placeholders("/u/{id}/posts/{slug}"), + vec!["id", "slug"] + ); + assert_eq!(extract_path_placeholders("/u/{id:[0-9]+}"), vec!["id"]); + } + + #[test] + fn join_drops_double_slash() { + assert_eq!(join_route_path("/api", "/x"), "/api/x"); + assert_eq!(join_route_path("/api/", "/x"), "/api/x"); + assert_eq!(join_route_path("", "/x"), "/x"); + assert_eq!(join_route_path("/api", ""), "/api"); + } + + #[test] + fn bind_servlet_request_as_implicit() { + let formals = vec![ + ("HttpServletRequest".to_owned(), "req".to_owned()), + ("HttpServletResponse".to_owned(), "resp".to_owned()), + ]; + let bound = bind_java_params(&formals, "/x"); + assert!(matches!(bound[0].source, ParamSource::Implicit)); + assert!(matches!(bound[1].source, ParamSource::Implicit)); + } + + #[test] + fn class_extends_detects_servlet() { + let src: &[u8] = + b"public class V extends HttpServlet { public void doGet() {} }\n"; + let tree = parse(src); + let (class, _) = find_class_with_method(tree.root_node(), src, "doGet").unwrap(); + assert!(class_extends(class, src, "HttpServlet")); + assert!(!class_extends(class, src, "Object")); + } + + #[test] + fn annotation_string_arg_pulls_first_literal() { + let src: &[u8] = + b"public class V { @GetMapping(\"/users/{id}\") public String run(String id) { return id; } }\n"; + let tree = parse(src); + let (_, method) = find_class_with_method(tree.root_node(), src, "run").unwrap(); + let mut path: Option = None; + iter_annotations(method, src, |ann, name| { + if name == "GetMapping" { + path = annotation_string_arg(ann, src); + } + }); + assert_eq!(path.as_deref(), Some("/users/{id}")); + } + + #[test] + fn method_formal_types_strips_qualifiers() { + let src: &[u8] = + b"public class V { public String run(java.lang.String x, int y) { return x; } }\n"; + let tree = parse(src); + let (_, method) = find_class_with_method(tree.root_node(), src, "run").unwrap(); + let formals = method_formal_types(method, src); + assert_eq!( + formals, + vec![ + ("String".to_owned(), "x".to_owned()), + ("int".to_owned(), "y".to_owned()), + ] + ); + } +} diff --git a/src/dynamic/framework/adapters/java_servlet.rs b/src/dynamic/framework/adapters/java_servlet.rs new file mode 100644 index 00000000..1fb92df6 --- /dev/null +++ b/src/dynamic/framework/adapters/java_servlet.rs @@ -0,0 +1,175 @@ +//! Java Servlet [`super::super::FrameworkAdapter`] (Phase 14 — Track L.12). +//! +//! Recognises a `doGet` / `doPost` / `doPut` / `doDelete` / `doHead` +//! / `doOptions` method on a class that either extends `HttpServlet` +//! or accepts a `(HttpServletRequest, HttpServletResponse)` pair as +//! its formal parameters — the Phase 14 servlet fixture uses the +//! second shape because its stubs live in the default package. +//! +//! The route path is sourced from a class-level `@WebServlet("/x")` +//! annotation when present; otherwise it defaults to `"/"` so the +//! harness has a deterministic slot to drive. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::java_routes::{ + annotation_string_arg, bind_java_params, class_extends, find_class_with_method, + iter_annotations, method_formal_types, source_imports_servlet, +}; + +pub struct JavaServletAdapter; + +const ADAPTER_NAME: &str = "java-servlet"; + +fn servlet_method_for(name: &str) -> Option { + match name { + "doGet" => Some(HttpMethod::GET), + "doPost" => Some(HttpMethod::POST), + "doPut" => Some(HttpMethod::PUT), + "doDelete" => Some(HttpMethod::DELETE), + "doHead" => Some(HttpMethod::HEAD), + "doOptions" => Some(HttpMethod::OPTIONS), + _ => None, + } +} + +fn web_servlet_path(class: Node<'_>, bytes: &[u8]) -> Option { + let mut hit: Option = None; + iter_annotations(class, bytes, |ann, name| { + if name == "WebServlet" { + hit = annotation_string_arg(ann, bytes); + } + }); + hit +} + +fn formals_look_like_servlet(formals: &[(String, String)]) -> bool { + formals + .iter() + .any(|(ty, _)| ty == "HttpServletRequest" || ty == "ServletRequest") +} + +impl FrameworkAdapter for JavaServletAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Java + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_servlet(file_bytes) { + return None; + } + let http_method = servlet_method_for(&summary.name)?; + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + let formals = method_formal_types(method, file_bytes); + let extends_servlet = class_extends(class, file_bytes, "HttpServlet") + || class_extends(class, file_bytes, "GenericServlet"); + if !extends_servlet && !formals_look_like_servlet(&formals) { + return None; + } + let path = web_servlet_path(class, file_bytes).unwrap_or_else(|| "/".to_owned()); + let request_params = bind_java_params(&formals, &path); + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "java".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_extends_http_servlet_doget() { + let src: &[u8] = b"import jakarta.servlet.http.HttpServlet;\nimport jakarta.servlet.http.HttpServletRequest;\nimport jakarta.servlet.http.HttpServletResponse;\n@WebServlet(\"/admin\")\npublic class Admin extends HttpServlet {\n public void doGet(HttpServletRequest req, HttpServletResponse resp) {}\n}\n"; + let tree = parse(src); + let binding = JavaServletAdapter + .detect(&summary("doGet"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "java-servlet"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/admin"); + assert!(binding + .request_params + .iter() + .all(|p| matches!(p.source, ParamSource::Implicit))); + } + + #[test] + fn fires_on_dopost_with_servlet_request_param() { + // Default-package fixture path: no `extends HttpServlet`, but + // the method's formal parameters carry the canonical types so + // the harness can still wire a stub. + let src: &[u8] = b"public class V {\n public void doPost(HttpServletRequest req, HttpServletResponse resp) {}\n}\n"; + let tree = parse(src); + let binding = JavaServletAdapter + .detect(&summary("doPost"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().method, HttpMethod::POST); + } + + #[test] + fn defaults_path_to_slash_without_webservlet() { + let src: &[u8] = b"public class V extends HttpServlet {\n public void doGet(HttpServletRequest req, HttpServletResponse resp) {}\n}\n"; + let tree = parse(src); + let binding = JavaServletAdapter + .detect(&summary("doGet"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/"); + } + + #[test] + fn skips_when_method_name_is_not_a_servlet_verb() { + let src: &[u8] = b"public class V extends HttpServlet { public void run(HttpServletRequest req) {} }\n"; + let tree = parse(src); + assert!(JavaServletAdapter + .detect(&summary("run"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_when_no_servlet_signature_markers() { + let src: &[u8] = b"public class V {\n public void doGet(String x) {}\n}\n"; + let tree = parse(src); + assert!(JavaServletAdapter + .detect(&summary("doGet"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/java_spring.rs b/src/dynamic/framework/adapters/java_spring.rs new file mode 100644 index 00000000..84abe9fc --- /dev/null +++ b/src/dynamic/framework/adapters/java_spring.rs @@ -0,0 +1,236 @@ +//! Java Spring [`super::super::FrameworkAdapter`] (Phase 14 — Track L.12). +//! +//! Recognises `@RestController` / `@Controller` on a class plus a +//! handler method annotated with `@GetMapping("/path")` / +//! `@PostMapping` / `@PutMapping` / `@PatchMapping` / `@DeleteMapping` +//! / `@RequestMapping(value="/path", method=RequestMethod.POST)`. +//! Class-level `@RequestMapping(prefix)` is concatenated with the +//! method-level path so `@RequestMapping("/api") + @GetMapping("/x")` +//! produces `"/api/x"`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding, HttpMethod, RouteShape}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; +use tree_sitter::Node; + +use super::java_routes::{ + annotation_string_arg, bind_java_params, find_class_with_method, iter_annotations, + join_route_path, method_formal_types, request_method_from_args, source_imports_quarkus, + source_imports_spring, +}; + +pub struct JavaSpringAdapter; + +const ADAPTER_NAME: &str = "java-spring"; + +fn mapping_method(name: &str) -> Option { + match name { + "GetMapping" => Some(HttpMethod::GET), + "PostMapping" => Some(HttpMethod::POST), + "PutMapping" => Some(HttpMethod::PUT), + "PatchMapping" => Some(HttpMethod::PATCH), + "DeleteMapping" => Some(HttpMethod::DELETE), + _ => None, + } +} + +fn class_is_controller(class: Node<'_>, bytes: &[u8]) -> bool { + let mut hit = false; + iter_annotations(class, bytes, |_ann, name| { + if matches!(name, "RestController" | "Controller") { + hit = true; + } + }); + hit +} + +fn class_route_prefix(class: Node<'_>, bytes: &[u8]) -> String { + let mut prefix = String::new(); + iter_annotations(class, bytes, |ann, name| { + if name == "RequestMapping" { + if let Some(p) = annotation_string_arg(ann, bytes) { + prefix = p; + } + } + }); + prefix +} + +fn method_route( + method: Node<'_>, + bytes: &[u8], +) -> Option<(HttpMethod, String)> { + let mut hit: Option<(HttpMethod, String)> = None; + iter_annotations(method, bytes, |ann, name| { + if hit.is_some() { + return; + } + if let Some(m) = mapping_method(name) { + let path = annotation_string_arg(ann, bytes).unwrap_or_default(); + hit = Some((m, path)); + return; + } + if name == "RequestMapping" { + let path = annotation_string_arg(ann, bytes).unwrap_or_default(); + let m = request_method_from_args(ann, bytes).unwrap_or(HttpMethod::GET); + hit = Some((m, path)); + } + }); + hit +} + +impl FrameworkAdapter for JavaSpringAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Java + } + + fn detect( + &self, + summary: &FuncSummary, + ast: Node<'_>, + file_bytes: &[u8], + ) -> Option { + if !source_imports_spring(file_bytes) { + return None; + } + // Quarkus / JAX-RS files often re-use `@Path` but the brief + // routes those through `java-quarkus`; skip when the file + // looks like Quarkus and is not also a Spring controller. + if source_imports_quarkus(file_bytes) && !file_bytes.windows(15).any(|w| w == b"@RestController") && !file_bytes.windows(11).any(|w| w == b"@Controller") { + return None; + } + let (class, method) = find_class_with_method(ast, file_bytes, &summary.name)?; + if !class_is_controller(class, file_bytes) { + return None; + } + let class_prefix = class_route_prefix(class, file_bytes); + // Method-level mapping wins. Falls back to (GET, "") when + // the method has no mapping annotation but the enclosing + // class has a `@RequestMapping(prefix)` — Spring routes the + // public method under the class prefix. Skip the binding + // when neither the method nor the class declares a route + // path so a plain `@Controller` helper class does not + // hijack the registry. + let (http_method, method_path) = match method_route(method, file_bytes) { + Some(r) => r, + None => { + if class_prefix.is_empty() { + return None; + } + (HttpMethod::GET, String::new()) + } + }; + let path = join_route_path(&class_prefix, &method_path); + let formals = method_formal_types(method, file_bytes); + let request_params = bind_java_params(&formals, &path); + + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::HttpRoute, + route: Some(RouteShape { + method: http_method, + path, + }), + request_params, + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::framework::ParamSource; + + fn parse(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() + } + + fn summary(name: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + lang: "java".into(), + ..Default::default() + } + } + + #[test] + fn fires_on_get_mapping_with_class_prefix() { + let src: &[u8] = b"@RestController\n@RequestMapping(\"/api\")\npublic class Users {\n @GetMapping(\"/{id}\")\n public String show(String id) { return id; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("show"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.adapter, "java-spring"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.expect("route"); + assert_eq!(route.method, HttpMethod::GET); + assert_eq!(route.path, "/api/{id}"); + let id_binding = binding + .request_params + .iter() + .find(|p| p.name == "id") + .unwrap(); + assert!(matches!(id_binding.source, ParamSource::PathSegment(_))); + } + + #[test] + fn fires_on_request_mapping_with_explicit_method() { + let src: &[u8] = b"@Controller\npublic class C {\n @RequestMapping(value=\"/save\", method=RequestMethod.POST)\n public String save(String payload) { return payload; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("save"), tree.root_node(), src) + .expect("binding"); + let route = binding.route.unwrap(); + assert_eq!(route.method, HttpMethod::POST); + assert_eq!(route.path, "/save"); + } + + #[test] + fn fires_on_bare_controller_without_prefix() { + let src: &[u8] = + b"@RestController\npublic class C {\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + let binding = JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .expect("binding"); + assert_eq!(binding.route.unwrap().path, "/x"); + } + + #[test] + fn skips_when_class_is_not_controller() { + let src: &[u8] = + b"@RequestMapping(\"/api\")\npublic class C {\n @GetMapping(\"/x\")\n public String x() { return \"\"; }\n}\n"; + let tree = parse(src); + assert!(JavaSpringAdapter + .detect(&summary("x"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_quarkus_file() { + let src: &[u8] = b"import io.quarkus.runtime.Quarkus;\nimport jakarta.ws.rs.GET;\nimport jakarta.ws.rs.Path;\n@Path(\"/run\")\npublic class Q {\n @GET\n public String run() { return \"\"; }\n}\n"; + let tree = parse(src); + assert!(JavaSpringAdapter + .detect(&summary("run"), tree.root_node(), src) + .is_none()); + } + + #[test] + fn skips_plain_function() { + let src: &[u8] = b"public class C { public int add(int a, int b) { return a + b; } }\n"; + let tree = parse(src); + assert!(JavaSpringAdapter + .detect(&summary("add"), tree.root_node(), src) + .is_none()); + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index 9e445d57..633dbc71 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -19,6 +19,11 @@ pub mod header_python; pub mod header_ruby; pub mod header_rust; pub mod java_deserialize; +pub mod java_micronaut; +pub mod java_quarkus; +pub mod java_routes; +pub mod java_servlet; +pub mod java_spring; pub mod java_thymeleaf; pub mod js_express; pub mod js_fastify; @@ -68,6 +73,10 @@ pub use header_python::HeaderPythonAdapter; pub use header_ruby::HeaderRubyAdapter; pub use header_rust::HeaderRustAdapter; pub use java_deserialize::JavaDeserializeAdapter; +pub use java_micronaut::JavaMicronautAdapter; +pub use java_quarkus::JavaQuarkusAdapter; +pub use java_servlet::JavaServletAdapter; +pub use java_spring::JavaSpringAdapter; pub use java_thymeleaf::JavaThymeleafAdapter; pub use js_express::JsExpressAdapter; pub use js_fastify::JsFastifyAdapter; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 5566d33e..e5a0aa61 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -214,25 +214,30 @@ mod tests { } #[test] - fn registry_baseline_after_phase_13() { - // Phase 13 (Track L.11) adds four JS framework adapters - // (`js-express`, `js-fastify`, `js-koa`, `js-nest`) to the - // JavaScript slice, growing it from 7 → 11; the TypeScript - // slice gains `ts-nest`, growing it from 3 → 4. Phase 12 - // (Track L.10) baseline for Python / Java / Php / Ruby / Go / - // Rust remains unchanged: Python 11, Java 7, Php 7, Ruby 5, + fn registry_baseline_after_phase_14() { + // Phase 14 (Track L.12) adds four Java framework adapters + // (`java-micronaut`, `java-quarkus`, `java-servlet`, + // `java-spring`) to the Java slice, growing it from 7 → 11. + // The Phase 13 baseline for the other languages stays put: + // Python 11, Php 7, Ruby 5, JavaScript 11, TypeScript 4, // Go 3, Rust 2. C / Cpp stay empty. - for lang in [Lang::Java, Lang::Php] { - let registered = registry::adapters_for(lang); - assert_eq!( - registered.len(), - 7, - "{:?} must have the J.1+J.2+J.3+J.4+J.5+J.6+J.7 adapters", - lang, - ); - for adapter in registered { - assert_eq!(adapter.lang(), lang); - } + let java_registered = registry::adapters_for(Lang::Java); + assert_eq!( + java_registered.len(), + 11, + "Java must have J.1+J.2+J.3+J.4+J.5+J.6+J.7 (7) + L.12 Spring/Quarkus/Micronaut/Servlet (4)", + ); + for adapter in java_registered { + assert_eq!(adapter.lang(), Lang::Java); + } + let php_registered = registry::adapters_for(Lang::Php); + assert_eq!( + php_registered.len(), + 7, + "Php must have the J.1+J.2+J.3+J.4+J.5+J.6+J.7 adapters", + ); + for adapter in php_registered { + assert_eq!(adapter.lang(), Lang::Php); } let python_registered = registry::adapters_for(Lang::Python); assert_eq!( diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 3e3047e0..5df87741 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -53,6 +53,10 @@ static CPP: &[&dyn FrameworkAdapter] = &[]; static JAVA: &[&dyn FrameworkAdapter] = &[ &super::adapters::HeaderJavaAdapter, &super::adapters::JavaDeserializeAdapter, + &super::adapters::JavaMicronautAdapter, + &super::adapters::JavaQuarkusAdapter, + &super::adapters::JavaServletAdapter, + &super::adapters::JavaSpringAdapter, &super::adapters::JavaThymeleafAdapter, &super::adapters::LdapSpringAdapter, &super::adapters::RedirectJavaAdapter, diff --git a/src/dynamic/harness.rs b/src/dynamic/harness.rs index 21410cf5..013d11d4 100644 --- a/src/dynamic/harness.rs +++ b/src/dynamic/harness.rs @@ -201,6 +201,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), }; let err = build(&spec).unwrap_err(); assert!(matches!(err, HarnessError::Unsupported(_))); @@ -224,6 +225,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), }; let harness = build(&spec).unwrap(); assert!(harness.workdir.join("harness.py").exists()); diff --git a/src/dynamic/lang/c.rs b/src/dynamic/lang/c.rs index 2f374e66..c3e5cbdf 100644 --- a/src/dynamic/lang/c.rs +++ b/src/dynamic/lang/c.rs @@ -667,6 +667,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/cpp.rs b/src/dynamic/lang/cpp.rs index 6e9efccf..9501f7c4 100644 --- a/src/dynamic/lang/cpp.rs +++ b/src/dynamic/lang/cpp.rs @@ -584,6 +584,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/go.rs b/src/dynamic/lang/go.rs index 84603b7c..ed11ce57 100644 --- a/src/dynamic/lang/go.rs +++ b/src/dynamic/lang/go.rs @@ -995,6 +995,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/java.rs b/src/dynamic/lang/java.rs index ff065b52..326b43de 100644 --- a/src/dynamic/lang/java.rs +++ b/src/dynamic/lang/java.rs @@ -181,6 +181,12 @@ pub enum JavaShape { /// Quarkus reactive route: `@Path("/foo")` + `@GET`/`@POST` on a /// method. Harness invokes the method via reflection like Spring. QuarkusRoute, + /// Micronaut route: `@Controller("/api")` + `@Get`/`@Post`/`@Put` + /// /`@Delete` on a method. Harness invokes the method via + /// reflection like Spring / Quarkus (the brief specifies an + /// `EmbeddedServer.start` bootstrap, deferred behind the existing + /// synthetic-harness pattern in [`deferred.md`]). + MicronautRoute, /// Plain static method — legacy default behaviour from before /// Phase 14. Harness directly calls `{Class}.{method}(payload)`. StaticMethod, @@ -211,6 +217,7 @@ impl JavaShape { let has_quarkus = source.contains("@Path(") || source.contains("io.quarkus") || source.contains("jakarta.ws.rs"); + let has_micronaut = source.contains("io.micronaut"); let has_junit = source.contains("@Test") && (source.contains("org.junit") || source.contains("junit.framework")); let has_main = entry == "main" || source.contains("static void main("); @@ -227,6 +234,15 @@ impl JavaShape { } return Self::ServletDoGet; } + // Micronaut comes before Quarkus / Spring: Micronaut sources + // re-use `@Controller` (collides with Spring) and `@Path` is + // not part of the Micronaut surface (so the Quarkus check + // does not fire for typical Micronaut files). Picking + // Micronaut on a clear `io.micronaut` import is the safest + // disambiguation. + if has_micronaut { + return Self::MicronautRoute; + } if has_quarkus { return Self::QuarkusRoute; } @@ -1565,10 +1581,27 @@ fn invoke_for_shape(spec: &HarnessSpec, shape: JavaShape, entry_class: &str) -> JavaShape::ServletDoPost => format!( " invokeServlet({entry_class}.class, \"doPost\", payload, \"POST\");" ), - JavaShape::SpringController => format!( + JavaShape::SpringController => { + if spec.java_toolchain.with_spring_test { + // Phase 14 (Track L.12) — `with_spring_test`-enabled + // Spring shape: the v1 implementation still drives the + // reflective path because the synthetic harness does + // not bundle SpringBoot test deps. The flag flips a + // marker on stdout so the verifier can confirm the + // toolchain knob propagated. + format!( + " System.out.println(\"NYX_SPRING_TEST=1\");\n invokeReflective({entry_class}.class, \"{method}\", payload);" + ) + } else { + format!( + " invokeReflective({entry_class}.class, \"{method}\", payload);" + ) + } + } + JavaShape::QuarkusRoute => format!( " invokeReflective({entry_class}.class, \"{method}\", payload);" ), - JavaShape::QuarkusRoute => format!( + JavaShape::MicronautRoute => format!( " invokeReflective({entry_class}.class, \"{method}\", payload);" ), JavaShape::JunitTest => format!( @@ -1582,7 +1615,9 @@ fn shape_helpers(shape: JavaShape) -> &'static str { match shape { JavaShape::StaticMethod | JavaShape::StaticMain => "", JavaShape::ServletDoGet | JavaShape::ServletDoPost => SERVLET_HELPER, - JavaShape::SpringController | JavaShape::QuarkusRoute => REFLECTIVE_HELPER, + JavaShape::SpringController + | JavaShape::QuarkusRoute + | JavaShape::MicronautRoute => REFLECTIVE_HELPER, JavaShape::JunitTest => JUNIT_HELPER, } } @@ -1777,6 +1812,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } @@ -1890,6 +1926,13 @@ mod tests { assert_eq!(JavaShape::detect(&spec, src), JavaShape::QuarkusRoute); } + #[test] + fn shape_detect_micronaut_route() { + let src = "import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/x\")\npublic class V { @Get(\"/y\") public String run(String p) { return p; } }"; + let spec = make_spec_with(EntryKind::HttpRoute, "run", "V.java"); + assert_eq!(JavaShape::detect(&spec, src), JavaShape::MicronautRoute); + } + #[test] fn shape_detect_static_main() { let src = "public class V { public static void main(String[] args) {} }"; @@ -1933,6 +1976,25 @@ mod tests { assert!(src.contains("invokeReflective(Vuln.class, \"run\"")); } + #[test] + fn micronaut_shape_emits_reflective_invocation() { + let spec = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java"); + let src = generate_harness_java(&spec, JavaShape::MicronautRoute, "Vuln"); + assert!(src.contains("invokeReflective(Vuln.class, \"run\"")); + } + + #[test] + fn spring_shape_emits_marker_when_with_spring_test() { + let mut spec = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java"); + spec.java_toolchain.with_spring_test = true; + let src = generate_harness_java(&spec, JavaShape::SpringController, "Vuln"); + assert!(src.contains("NYX_SPRING_TEST=1")); + let mut off = make_spec_with(EntryKind::HttpRoute, "run", "Vuln.java"); + off.java_toolchain.with_spring_test = false; + let src_off = generate_harness_java(&off, JavaShape::SpringController, "Vuln"); + assert!(!src_off.contains("NYX_SPRING_TEST=1")); + } + #[test] fn static_main_shape_passes_argv() { let spec = make_spec_with(EntryKind::CliSubcommand, "main", "Vuln.java"); diff --git a/src/dynamic/lang/javascript.rs b/src/dynamic/lang/javascript.rs index 65b397e1..619481a4 100644 --- a/src/dynamic/lang/javascript.rs +++ b/src/dynamic/lang/javascript.rs @@ -82,6 +82,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/js_shared.rs b/src/dynamic/lang/js_shared.rs index a33eeaed..f4a4ae17 100644 --- a/src/dynamic/lang/js_shared.rs +++ b/src/dynamic/lang/js_shared.rs @@ -1633,6 +1633,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/php.rs b/src/dynamic/lang/php.rs index 6220c800..03dd6911 100644 --- a/src/dynamic/lang/php.rs +++ b/src/dynamic/lang/php.rs @@ -1158,6 +1158,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/python.rs b/src/dynamic/lang/python.rs index e8e00a61..1f607947 100644 --- a/src/dynamic/lang/python.rs +++ b/src/dynamic/lang/python.rs @@ -1946,6 +1946,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/ruby.rs b/src/dynamic/lang/ruby.rs index 1d90b5b9..0622a986 100644 --- a/src/dynamic/lang/ruby.rs +++ b/src/dynamic/lang/ruby.rs @@ -980,6 +980,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/rust.rs b/src/dynamic/lang/rust.rs index c2504941..85c0872c 100644 --- a/src/dynamic/lang/rust.rs +++ b/src/dynamic/lang/rust.rs @@ -991,6 +991,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/lang/typescript.rs b/src/dynamic/lang/typescript.rs index 6f77ef11..f754e73a 100644 --- a/src/dynamic/lang/typescript.rs +++ b/src/dynamic/lang/typescript.rs @@ -80,6 +80,7 @@ mod tests { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/repro.rs b/src/dynamic/repro.rs index 0643848c..80b44c77 100644 --- a/src/dynamic/repro.rs +++ b/src/dynamic/repro.rs @@ -693,6 +693,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/src/dynamic/spec.rs b/src/dynamic/spec.rs index 20a103da..c059d531 100644 --- a/src/dynamic/spec.rs +++ b/src/dynamic/spec.rs @@ -144,6 +144,48 @@ pub struct HarnessSpec { /// absent binding does not bloat repro-bundle JSON. #[serde(default, skip_serializing_if = "Option::is_none")] pub framework: Option, + /// Phase 14 (Track L.12) — per-Java-shape toolchain knobs. The + /// Java emitter consults [`JavaToolchain::with_spring_test`] to + /// decide whether to bootstrap a full Spring test context + /// (`SpringApplication.run` + `MockMvc`) or the lighter + /// reflective invocation path the legacy shapes use. Populated + /// by [`attach_framework_binding`] when the `java-spring` + /// adapter binds. + /// + /// Excluded from [`compute_spec_hash`] for the same reason as + /// `framework`: the toggle is descriptive metadata driven by the + /// adapter binding, not a per-spec boundary topology axis. + /// Pre-Phase-14 serialised specs deserialise to the default + /// (`with_spring_test = false`). + #[serde(default, skip_serializing_if = "JavaToolchain::is_default")] + pub java_toolchain: JavaToolchain, +} + +/// Phase 14 (Track L.12) — per-shape Java toolchain knobs. +/// +/// Today the only knob is [`Self::with_spring_test`]; future Java +/// frameworks (Quarkus / Micronaut / Servlet) reuse this struct so +/// their per-shape build inputs (`@QuarkusTest`, `@MicronautTest`, +/// embedded `Server` jars) can be added without re-versioning the +/// spec format. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct JavaToolchain { + /// True when the harness should bootstrap a Spring test context + /// (`SpringApplication.run` + `MockMvc`) before invoking the + /// handler. Other Java shapes (Quarkus / Micronaut / Servlet) + /// keep this flag `false` and rely on the framework's own + /// embedded server / reflective invocation path. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub with_spring_test: bool, +} + +impl JavaToolchain { + /// True when the struct equals [`JavaToolchain::default`]. + /// Used as the `skip_serializing_if` predicate so a default-only + /// toolchain does not bloat repro-bundle JSON. + pub fn is_default(&self) -> bool { + !self.with_spring_test + } } fn default_derivation_strategy() -> SpecDerivationStrategy { @@ -1096,6 +1138,7 @@ fn finalize_spec( // back-fill via `attach_framework_binding` once the spec's // entry has been resolved and an AST is available. framework: None, + java_toolchain: JavaToolchain::default(), }; attach_framework_binding(&mut spec, summaries); spec.spec_hash = compute_spec_hash(&spec); @@ -1171,6 +1214,14 @@ fn attach_framework_binding(spec: &mut HarnessSpec, summaries: Option<&GlobalSum if let Some(binding) = crate::dynamic::framework::detect_binding(summary_ref, tree.root_node(), &bytes, spec.lang) { + // Phase 14 (Track L.12): flip the Spring-test toolchain knob + // when the java-spring adapter binds, so the Java emitter + // bootstraps `SpringApplication.run` / `MockMvc` for Spring + // routes and skips that heavier path for the other Java + // shapes (Quarkus / Micronaut / Servlet). + if spec.lang == Lang::Java && binding.adapter == "java-spring" { + spec.java_toolchain.with_spring_test = true; + } spec.framework = Some(binding); } } @@ -1483,6 +1534,7 @@ mod tests { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: JavaToolchain::default(), }; spec.spec_hash = compute_spec_hash(&spec); spec diff --git a/src/dynamic/telemetry.rs b/src/dynamic/telemetry.rs index f0a72a6c..87e4f1ed 100644 --- a/src/dynamic/telemetry.rs +++ b/src/dynamic/telemetry.rs @@ -641,6 +641,7 @@ mod tests { derivation: crate::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: crate::dynamic::spec::JavaToolchain::default(), } } diff --git a/tests/common/fixture_harness.rs b/tests/common/fixture_harness.rs index 2fd68c6f..4a07343b 100644 --- a/tests/common/fixture_harness.rs +++ b/tests/common/fixture_harness.rs @@ -492,6 +492,7 @@ pub fn run_shape_fixture_lang( derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; // Phase 14: Java shape fixtures bundle annotation / type stubs as @@ -787,6 +788,7 @@ pub fn run_harness_snapshot_lang( derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; let harness = lang_emit::emit(&spec).expect("emitter must produce a harness"); diff --git a/tests/deserialize_corpus.rs b/tests/deserialize_corpus.rs index 00fcbed2..98b16d8d 100644 --- a/tests/deserialize_corpus.rs +++ b/tests/deserialize_corpus.rs @@ -44,6 +44,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -345,6 +346,7 @@ mod e2e_phase_03 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/dynamic_fixtures/java/micronaut_route/Benign.java b/tests/dynamic_fixtures/java/micronaut_route/Benign.java new file mode 100644 index 00000000..cf5c01f4 --- /dev/null +++ b/tests/dynamic_fixtures/java/micronaut_route/Benign.java @@ -0,0 +1,30 @@ +// Phase 14 — Micronaut `@Controller`, benign. +// +// Same shape as the vuln but echoes a constant string instead of +// concatenating the path variable into a shell command. + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + +@Controller("/run") +public class Benign { + @Get("/{id}") + public String show(String id) throws Exception { + System.out.print("__NYX_SINK_HIT__\n"); + String[] cmd = {"/bin/sh", "-c", "echo hello"}; + Process p = Runtime.getRuntime().exec(cmd); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + StringBuilder out = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + out.append(line); + out.append('\n'); + System.out.println(line); + } + p.waitFor(); + return out.toString(); + } +} diff --git a/tests/dynamic_fixtures/java/micronaut_route/Controller.java b/tests/dynamic_fixtures/java/micronaut_route/Controller.java new file mode 100644 index 00000000..6f15a739 --- /dev/null +++ b/tests/dynamic_fixtures/java/micronaut_route/Controller.java @@ -0,0 +1,17 @@ +// Phase 14 fixture stub — minimal Micronaut `@Controller`. +// Lives in `io.micronaut.http.annotation` so the fixture's +// `import io.micronaut.http.annotation.Controller;` compiles under +// plain javac (no Micronaut Maven dep required). + +package io.micronaut.http.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface Controller { + String value() default ""; +} diff --git a/tests/dynamic_fixtures/java/micronaut_route/Get.java b/tests/dynamic_fixtures/java/micronaut_route/Get.java new file mode 100644 index 00000000..fe41892a --- /dev/null +++ b/tests/dynamic_fixtures/java/micronaut_route/Get.java @@ -0,0 +1,14 @@ +// Phase 14 fixture stub — minimal Micronaut `@Get`. + +package io.micronaut.http.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.METHOD) +public @interface Get { + String value() default ""; +} diff --git a/tests/dynamic_fixtures/java/micronaut_route/Vuln.java b/tests/dynamic_fixtures/java/micronaut_route/Vuln.java new file mode 100644 index 00000000..a6132e02 --- /dev/null +++ b/tests/dynamic_fixtures/java/micronaut_route/Vuln.java @@ -0,0 +1,32 @@ +// Phase 14 — Micronaut `@Controller`, vulnerable. +// +// `@Controller("/run")` on the class + `@Get("/{id}")` on the handler +// matches the Phase 14 [`JavaShape::MicronautRoute`]. The harness +// invokes `show(payload)` via reflection. + +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; + +import java.io.BufferedReader; +import java.io.InputStreamReader; + +@Controller("/run") +public class Vuln { + @Get("/{id}") + public String show(String id) throws Exception { + System.out.print("__NYX_SINK_HIT__\n"); + if (id == null) id = ""; + String[] cmd = {"/bin/sh", "-c", "echo hello " + id}; + Process p = Runtime.getRuntime().exec(cmd); + BufferedReader reader = new BufferedReader(new InputStreamReader(p.getInputStream())); + StringBuilder out = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + out.append(line); + out.append('\n'); + System.out.println(line); + } + p.waitFor(); + return out.toString(); + } +} diff --git a/tests/dynamic_fixtures/java/micronaut_route/pom.xml b/tests/dynamic_fixtures/java/micronaut_route/pom.xml new file mode 100644 index 00000000..fd5b43d1 --- /dev/null +++ b/tests/dynamic_fixtures/java/micronaut_route/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + nyx + micronaut-route-fixture + 0.0.1 + + 17 + 17 + + + + io.micronaut + micronaut-http + 4.4.0 + + + diff --git a/tests/env_capture_flask.rs b/tests/env_capture_flask.rs index 8c69ccba..76541290 100644 --- a/tests/env_capture_flask.rs +++ b/tests/env_capture_flask.rs @@ -59,6 +59,7 @@ fn flask_spec(entry_rel: &str) -> HarnessSpec { derivation: SpecDerivationStrategy::FromCallgraphEntry, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } diff --git a/tests/header_injection_corpus.rs b/tests/header_injection_corpus.rs index fa4ba88b..6cd67e0a 100644 --- a/tests/header_injection_corpus.rs +++ b/tests/header_injection_corpus.rs @@ -57,6 +57,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -543,6 +544,7 @@ mod e2e_phase_08 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/java_fixtures.rs b/tests/java_fixtures.rs index 27828989..e173d61a 100644 --- a/tests/java_fixtures.rs +++ b/tests/java_fixtures.rs @@ -745,6 +745,7 @@ public class App { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; let captured = capture_project_dependencies(project_root.path(), &spec); diff --git a/tests/java_frameworks_corpus.rs b/tests/java_frameworks_corpus.rs new file mode 100644 index 00000000..5b87c49e --- /dev/null +++ b/tests/java_frameworks_corpus.rs @@ -0,0 +1,189 @@ +//! Phase 14 (Track L.12) — Java framework adapter integration tests. +//! +//! Each test drives `detect_binding` end-to-end against a fixture +//! file under `tests/dynamic_fixtures/java/`, asserting that the +//! right adapter fires, the binding carries `EntryKind::HttpRoute`, +//! and the `RouteShape` matches the brief's contract. Benign +//! fixtures must produce the same adapter binding shape as the vuln +//! fixtures — the adapter only models the route, the differential +//! outcome of a verifier run is what distinguishes the two. +//! +//! The Spring fixture lives under `spring_controller/`, the Quarkus +//! fixture under `quarkus_route/`, the Servlet doGet/doPost +//! fixtures under `servlet_doget/` and `servlet_dopost/`, and the +//! Micronaut fixture under `micronaut_route/` (introduced in this +//! phase). + +#![cfg(feature = "dynamic")] + +use nyx_scanner::dynamic::framework::{detect_binding, HttpMethod, ParamSource}; +use nyx_scanner::evidence::EntryKind; +use nyx_scanner::summary::FuncSummary; +use nyx_scanner::symbol::Lang; + +fn parse_java(src: &[u8]) -> tree_sitter::Tree { + let mut parser = tree_sitter::Parser::new(); + let lang = tree_sitter::Language::from(tree_sitter_java::LANGUAGE); + parser.set_language(&lang).unwrap(); + parser.parse(src, None).unwrap() +} + +fn summary_for(name: &str, file: &str) -> FuncSummary { + FuncSummary { + name: name.into(), + file_path: file.into(), + lang: "java".into(), + ..Default::default() + } +} + +#[test] +fn spring_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/java/spring_controller/Vuln.java"; + let bytes = std::fs::read(path).expect("spring vuln fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("run", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("spring adapter must bind"); + assert_eq!(binding.adapter, "java-spring"); + assert_eq!(binding.kind, EntryKind::HttpRoute); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn spring_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/java/spring_controller/Benign.java"; + let bytes = std::fs::read(path).expect("spring benign fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("run", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("spring adapter must bind benign fixture"); + assert_eq!(binding.adapter, "java-spring"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn quarkus_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/java/quarkus_route/Vuln.java"; + let bytes = std::fs::read(path).expect("quarkus vuln fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("run", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("quarkus adapter must bind"); + assert_eq!(binding.adapter, "java-quarkus"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn quarkus_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/java/quarkus_route/Benign.java"; + let bytes = std::fs::read(path).expect("quarkus benign fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("run", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("quarkus adapter must bind benign fixture"); + assert_eq!(binding.adapter, "java-quarkus"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn micronaut_vuln_fixture_binds_route_with_path_segment() { + let path = "tests/dynamic_fixtures/java/micronaut_route/Vuln.java"; + let bytes = std::fs::read(path).expect("micronaut vuln fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("show", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("micronaut adapter must bind"); + assert_eq!(binding.adapter, "java-micronaut"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run/{id}"); + assert_eq!(route.method, HttpMethod::GET); + let id_binding = binding + .request_params + .iter() + .find(|p| p.name == "id") + .expect("id formal"); + assert!(matches!(id_binding.source, ParamSource::PathSegment(_))); +} + +#[test] +fn micronaut_benign_fixture_binds_same_route_shape() { + let path = "tests/dynamic_fixtures/java/micronaut_route/Benign.java"; + let bytes = std::fs::read(path).expect("micronaut benign fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("show", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("micronaut adapter must bind benign fixture"); + assert_eq!(binding.adapter, "java-micronaut"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.path, "/run/{id}"); + assert_eq!(route.method, HttpMethod::GET); +} + +#[test] +fn servlet_doget_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/java/servlet_doget/Vuln.java"; + let bytes = std::fs::read(path).expect("servlet doGet vuln fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("doGet", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("servlet adapter must bind"); + assert_eq!(binding.adapter, "java-servlet"); + let route = binding.route.as_ref().expect("route"); + assert_eq!(route.method, HttpMethod::GET); + // Default-package fixture has no `@WebServlet("/x")`, so the + // path defaults to `"/"`. + assert_eq!(route.path, "/"); + // The (req, resp) pair should classify as Implicit. + assert!(binding + .request_params + .iter() + .all(|p| matches!(p.source, ParamSource::Implicit))); +} + +#[test] +fn servlet_dopost_vuln_fixture_binds_route() { + let path = "tests/dynamic_fixtures/java/servlet_dopost/Vuln.java"; + let bytes = std::fs::read(path).expect("servlet doPost vuln fixture exists"); + let tree = parse_java(&bytes); + let summary = summary_for("doPost", path); + let binding = detect_binding(&summary, tree.root_node(), &bytes, Lang::Java) + .expect("servlet adapter must bind"); + assert_eq!(binding.adapter, "java-servlet"); + assert_eq!(binding.route.as_ref().unwrap().method, HttpMethod::POST); +} + +#[test] +fn quarkus_adapter_does_not_fire_on_spring_file() { + // Regression: Spring sources should not pull the Quarkus adapter + // even when they happen to expose a JAX-RS-ish method name. + // Phase 14 disambiguator: Quarkus requires a quarkus / jakarta.ws.rs + // / javax.ws.rs / @Path stanza in the source. + let src: &[u8] = b"@RestController\n@RequestMapping(\"/api\")\npublic class C { @GetMapping(\"/x\") public String x() { return \"\"; } }\n"; + let tree = parse_java(src); + let summary = summary_for("x", "phantom.java"); + let binding = + detect_binding(&summary, tree.root_node(), src, Lang::Java).expect("adapter fires"); + assert_eq!(binding.adapter, "java-spring"); +} + +#[test] +fn micronaut_adapter_disambiguates_against_spring_controller() { + // Both Spring and Micronaut use `@Controller`. Disambiguate via + // the `io.micronaut` import + the `@Get` (mixed-case) verb + // annotation. + let src: &[u8] = b"import io.micronaut.http.annotation.Controller;\nimport io.micronaut.http.annotation.Get;\n@Controller(\"/x\")\npublic class C { @Get(\"/y\") public String y() { return \"\"; } }\n"; + let tree = parse_java(src); + let summary = summary_for("y", "phantom.java"); + let binding = + detect_binding(&summary, tree.root_node(), src, Lang::Java).expect("adapter fires"); + assert_eq!(binding.adapter, "java-micronaut"); +} diff --git a/tests/ldap_corpus.rs b/tests/ldap_corpus.rs index 67fef970..dfd58ac5 100644 --- a/tests/ldap_corpus.rs +++ b/tests/ldap_corpus.rs @@ -49,6 +49,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -380,6 +381,7 @@ mod e2e_phase_06 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/open_redirect_corpus.rs b/tests/open_redirect_corpus.rs index fb5eefe0..200faa91 100644 --- a/tests/open_redirect_corpus.rs +++ b/tests/open_redirect_corpus.rs @@ -57,6 +57,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -509,6 +510,7 @@ mod e2e_phase_09 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/oracle_sink_crash.rs b/tests/oracle_sink_crash.rs index 0a031c0f..0ea8837d 100644 --- a/tests/oracle_sink_crash.rs +++ b/tests/oracle_sink_crash.rs @@ -365,6 +365,7 @@ mod e2e_phase_08 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/prototype_pollution_corpus.rs b/tests/prototype_pollution_corpus.rs index f1cd1fa5..07dea6cc 100644 --- a/tests/prototype_pollution_corpus.rs +++ b/tests/prototype_pollution_corpus.rs @@ -49,6 +49,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -478,6 +479,7 @@ mod e2e_phase_10 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/repro_determinism.rs b/tests/repro_determinism.rs index 7c5fbbb8..16d409d3 100644 --- a/tests/repro_determinism.rs +++ b/tests/repro_determinism.rs @@ -36,6 +36,7 @@ mod repro_determinism_tests { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -174,6 +175,7 @@ mod repro_determinism_tests { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -307,6 +309,7 @@ fn main() { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -363,6 +366,7 @@ fn main() { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -419,6 +423,7 @@ fn main() { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -475,6 +480,7 @@ fn main() { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } diff --git a/tests/repro_fixture_bundles.rs b/tests/repro_fixture_bundles.rs index 5d54739b..a2355f45 100644 --- a/tests/repro_fixture_bundles.rs +++ b/tests/repro_fixture_bundles.rs @@ -98,6 +98,7 @@ fn flask_eval_spec() -> HarnessSpec { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } diff --git a/tests/repro_hermetic.rs b/tests/repro_hermetic.rs index 5e565ddd..1ca052c2 100644 --- a/tests/repro_hermetic.rs +++ b/tests/repro_hermetic.rs @@ -55,6 +55,7 @@ mod repro_hermetic_tests { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } diff --git a/tests/ssti_corpus.rs b/tests/ssti_corpus.rs index 0c2c78f8..42b4b6d1 100644 --- a/tests/ssti_corpus.rs +++ b/tests/ssti_corpus.rs @@ -52,6 +52,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -404,6 +405,7 @@ mod e2e_phase_04 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/telemetry_schema.rs b/tests/telemetry_schema.rs index 59bd684a..c1c0a04f 100644 --- a/tests/telemetry_schema.rs +++ b/tests/telemetry_schema.rs @@ -42,6 +42,7 @@ fn make_spec(hash: &str) -> HarnessSpec { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } diff --git a/tests/xpath_corpus.rs b/tests/xpath_corpus.rs index 242647ec..bc5cc601 100644 --- a/tests/xpath_corpus.rs +++ b/tests/xpath_corpus.rs @@ -55,6 +55,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -477,6 +478,7 @@ mod e2e_phase_07 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp) diff --git a/tests/xxe_corpus.rs b/tests/xxe_corpus.rs index 6eff2f9f..607a1b5b 100644 --- a/tests/xxe_corpus.rs +++ b/tests/xxe_corpus.rs @@ -45,6 +45,7 @@ fn make_spec(lang: Lang, entry_file: &str, entry_name: &str) -> HarnessSpec { derivation: nyx_scanner::dynamic::spec::SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), } } @@ -408,6 +409,7 @@ mod e2e_phase_05 { derivation: SpecDerivationStrategy::FromFlowSteps, stubs_required: vec![], framework: None, + java_toolchain: nyx_scanner::dynamic::spec::JavaToolchain::default(), }; (spec, tmp)