//! Java + Spring framework probe. //! //! Recognises Spring controller methods annotated with //! `@RequestMapping` / `@GetMapping` / `@PostMapping` / `@PutMapping` //! / `@PatchMapping` / `@DeleteMapping`. The route path is the //! concatenation of class-level `@RequestMapping(value=...)` / //! `@RestController` and method-level `value=...` arguments. //! //! `auth_required` fires when the method, the enclosing class, or the //! `value=` argument lists a Spring-Security annotation //! ([`AUTH_ANNOTATIONS`]). use crate::entry_points::HttpMethod; use crate::surface::lang::common::{leaf_matches, loc_for, rel_file}; use crate::surface::{EntryPoint, Framework, SourceLocation, SurfaceNode}; use std::path::Path; use tree_sitter::{Node, Tree}; pub use crate::auth_analysis::auth_markers::SPRING_ANNOTATIONS as AUTH_ANNOTATIONS; const MAPPING_ANNOTATIONS: &[(&str, Option)] = &[ ("RequestMapping", None), ("GetMapping", Some(HttpMethod::GET)), ("PostMapping", Some(HttpMethod::POST)), ("PutMapping", Some(HttpMethod::PUT)), ("PatchMapping", Some(HttpMethod::PATCH)), ("DeleteMapping", Some(HttpMethod::DELETE)), ]; pub fn detect_spring_routes( tree: &Tree, bytes: &[u8], path: &Path, scan_root: Option<&Path>, ) -> Vec { let file_rel = rel_file(path, scan_root); let mut out = Vec::new(); walk_classes(tree.root_node(), &mut |class| { let class_path = class_request_mapping_path(class, bytes); let class_auth = class_has_auth_annotation(class, bytes); let Some(body) = crate::surface::lang::common::child_or_named(class, "class_body") else { return; }; let mut cursor = body.walk(); for member in body.children(&mut cursor) { if member.kind() != "method_declaration" { continue; } if let Some((method, route_path, auth)) = method_mapping(member, bytes, &class_path) { let auth_required = class_auth || auth; let handler_name = method_name(member, bytes).unwrap_or_default(); out.push(SurfaceNode::EntryPoint(EntryPoint { location: loc_for(member, &file_rel), framework: Framework::Spring, method, route: route_path, handler_name, handler_location: SourceLocation::new( file_rel.clone(), (member.start_position().row + 1) as u32, (member.start_position().column + 1) as u32, ), auth_required, })); } } }); out } fn walk_classes<'tree, F>(node: Node<'tree>, visit: &mut F) where F: FnMut(Node<'tree>), { if node.kind() == "class_declaration" { visit(node); } let mut cursor = node.walk(); for child in node.children(&mut cursor) { walk_classes(child, visit); } } fn class_request_mapping_path(class: Node, bytes: &[u8]) -> String { let modifiers = match crate::surface::lang::common::child_or_named(class, "modifiers") { Some(m) => m, None => return String::new(), }; let mut cursor = modifiers.walk(); for ann in modifiers.children(&mut cursor) { if !is_annotation(ann) { continue; } let Some((name, args_text)) = annotation_name_and_args(ann, bytes) else { continue; }; if name == "RequestMapping" { return extract_first_path(&args_text); } } String::new() } fn class_has_auth_annotation(class: Node, bytes: &[u8]) -> bool { let modifiers = match crate::surface::lang::common::child_or_named(class, "modifiers") { Some(m) => m, None => return false, }; let mut cursor = modifiers.walk(); for ann in modifiers.children(&mut cursor) { if !is_annotation(ann) { continue; } if let Some((name, _)) = annotation_name_and_args(ann, bytes) && AUTH_ANNOTATIONS.iter().any(|a| leaf_matches(&name, &[a])) { return true; } } false } fn method_mapping( method: Node, bytes: &[u8], class_path: &str, ) -> Option<(HttpMethod, String, bool)> { let modifiers = crate::surface::lang::common::child_or_named(method, "modifiers")?; let mut cursor = modifiers.walk(); let mut auth = false; let mut found: Option<(HttpMethod, String)> = None; for ann in modifiers.children(&mut cursor) { if !is_annotation(ann) { continue; } let Some((name, args_text)) = annotation_name_and_args(ann, bytes) else { continue; }; if AUTH_ANNOTATIONS.iter().any(|a| leaf_matches(&name, &[a])) { auth = true; } if found.is_some() { continue; } for (ann_name, default_method) in MAPPING_ANNOTATIONS { if name == *ann_name { let mut method_route = extract_first_path(&args_text); if method_route.is_empty() && !class_path.is_empty() { // Class-only mapping; method has no path. method_route = class_path.to_string(); } else if !class_path.is_empty() { method_route = format!( "{}/{}", class_path.trim_end_matches('/'), method_route.trim_start_matches('/') ); } let method = default_method .or_else(|| extract_request_method_from_args(&args_text)) .unwrap_or(HttpMethod::GET); found = Some((method, method_route)); break; } } } let (m, p) = found?; Some((m, p, auth)) } fn is_annotation(node: Node) -> bool { matches!(node.kind(), "annotation" | "marker_annotation") } /// Returns `(annotation_name, raw_args_text)` for an annotation node. fn annotation_name_and_args(ann: Node, bytes: &[u8]) -> Option<(String, String)> { let name_node = ann.child_by_field_name("name")?; let raw_name = name_node.utf8_text(bytes).ok()?; let leaf = raw_name.rsplit('.').next().unwrap_or(raw_name).to_string(); let args_text = ann .child_by_field_name("arguments") .and_then(|a| a.utf8_text(bytes).ok()) .unwrap_or("") .to_string(); Some((leaf, args_text)) } fn extract_first_path(args_text: &str) -> String { // Look for the first `"..."` literal. let mut chars = args_text.chars().peekable(); while let Some(c) = chars.next() { if c == '"' { let mut buf = String::new(); for c in chars.by_ref() { if c == '"' { return buf; } buf.push(c); } } } String::new() } fn extract_request_method_from_args(args_text: &str) -> Option { // RequestMapping(method = RequestMethod.POST) for verb in ["GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"] { if args_text.contains(&format!("RequestMethod.{}", verb)) { return HttpMethod::from_ident(verb); } } None } fn method_name(method: Node, bytes: &[u8]) -> Option { method .child_by_field_name("name") .and_then(|n| n.utf8_text(bytes).ok()) .map(str::to_string) } #[cfg(test)] mod tests { use super::*; use std::path::PathBuf; fn parse(src: &str) -> (Tree, Vec) { let mut parser = tree_sitter::Parser::new(); parser .set_language(&tree_sitter_java::LANGUAGE.into()) .unwrap(); (parser.parse(src, None).unwrap(), src.as_bytes().to_vec()) } #[test] fn detects_get_mapping() { let src = r#" @RestController public class UserController { @GetMapping("/users") public List list() { return null; } } "#; let (tree, bytes) = parse(src); let nodes = detect_spring_routes(&tree, &bytes, &PathBuf::from("UserController.java"), None); assert_eq!(nodes.len(), 1); let SurfaceNode::EntryPoint(ep) = &nodes[0] else { panic!() }; assert_eq!(ep.method, HttpMethod::GET); assert_eq!(ep.route, "/users"); assert_eq!(ep.handler_name, "list"); } #[test] fn class_request_mapping_prefix_concatenates() { let src = r#" @RequestMapping("/api") public class C { @PostMapping("/users") public void create() {} } "#; let (tree, bytes) = parse(src); let nodes = detect_spring_routes(&tree, &bytes, &PathBuf::from("C.java"), None); let SurfaceNode::EntryPoint(ep) = &nodes[0] else { panic!() }; assert_eq!(ep.route, "/api/users"); } #[test] fn pre_authorize_marks_auth() { let src = r#" public class C { @PreAuthorize("hasRole('ADMIN')") @GetMapping("/admin") public void admin() {} } "#; let (tree, bytes) = parse(src); let nodes = detect_spring_routes(&tree, &bytes, &PathBuf::from("C.java"), None); let SurfaceNode::EntryPoint(ep) = &nodes[0] else { panic!() }; assert!(ep.auth_required); } }