diff --git a/src/dynamic/framework/adapters/js_express.rs b/src/dynamic/framework/adapters/js_express.rs index a3643d54..3fe8638d 100644 --- a/src/dynamic/framework/adapters/js_express.rs +++ b/src/dynamic/framework/adapters/js_express.rs @@ -19,8 +19,8 @@ use crate::symbol::Lang; use tree_sitter::Node; use super::js_routes::{ - bind_path_params, find_function_params, find_route_registration, function_formal_names, - source_imports_express, + bind_path_params, extract_route_middleware, find_function_params, find_route_registration, + function_formal_names, source_imports_express, }; pub struct JsExpressAdapter; @@ -61,13 +61,14 @@ impl FrameworkAdapter for JsExpressAdapter { .map(|p| function_formal_names(p, file_bytes)) .unwrap_or_default(); let request_params = bind_path_params(&formals, &path); + let middleware = extract_route_middleware(ast, file_bytes, &summary.name, &recv); Some(FrameworkBinding { adapter: ADAPTER_NAME.to_owned(), kind: EntryKind::HttpRoute, route: Some(RouteShape { method, path }), request_params, response_writer: None, - middleware: Vec::new(), + middleware, }) } } @@ -145,7 +146,40 @@ mod tests { let binding = JsExpressAdapter .detect(&summary("handler"), tree.root_node(), src) .expect("binding"); - assert_eq!(binding.route.unwrap().method, HttpMethod::DELETE); + assert_eq!(binding.route.as_ref().unwrap().method, HttpMethod::DELETE); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["authz"]); + } + + #[test] + fn records_chained_middleware_and_global_app_use() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + app.use(helmet());\n\ + app.use(logger);\n\ + function authz(req, res, next) { next(); }\n\ + function validate(req, res, next) { next(); }\n\ + function handler(req, res) { res.send('ok'); }\n\ + app.post('/save', authz, validate, handler);\n"; + let tree = parse_js(src); + let binding = JsExpressAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + let names: Vec<_> = binding.middleware.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["helmet", "logger", "authz", "validate"]); + } + + #[test] + fn middleware_empty_when_route_has_no_chain() { + let src: &[u8] = b"const express = require('express');\n\ + const app = express();\n\ + function handler(req, res) { res.send('ok'); }\n\ + app.get('/x', handler);\n"; + let tree = parse_js(src); + let binding = JsExpressAdapter + .detect(&summary("handler"), tree.root_node(), src) + .expect("binding"); + assert!(binding.middleware.is_empty()); } #[test] diff --git a/src/dynamic/framework/adapters/js_routes.rs b/src/dynamic/framework/adapters/js_routes.rs index bc1e003f..7e505b2e 100644 --- a/src/dynamic/framework/adapters/js_routes.rs +++ b/src/dynamic/framework/adapters/js_routes.rs @@ -9,7 +9,7 @@ //! on whether a placeholder of the same name appears in the path //! template. -use crate::dynamic::framework::{HttpMethod, ParamBinding, ParamSource}; +use crate::dynamic::framework::{HttpMethod, MiddlewareShape, ParamBinding, ParamSource}; use tree_sitter::Node; /// True when `bytes` carries any of the well-known Express import @@ -486,6 +486,155 @@ pub fn first_string_arg(args: Node<'_>, bytes: &[u8]) -> Option { None } +/// Walk `root` collecting middleware names attached to a route +/// registration. Two sites are inspected: +/// +/// 1. The positional `.(, mw1, mw2, …, handler)` +/// chain on the matching route call — every identifier-shaped +/// positional argument between the path string and `target` +/// becomes a [`MiddlewareShape`]. +/// 2. Every preceding `.use()` call at the top level — +/// `` may be a bare identifier (`app.use(authMw)`) or a +/// call expression (`app.use(authMw())`), and the recorded name +/// is the identifier / called-function last segment. +/// +/// Names are recorded in source order: global `app.use(...)` first +/// (because they fire before the route), then per-route chained +/// middleware. Duplicate names are kept — repeated registrations are +/// real, e.g. `app.use(logger); app.use(logger);`. +pub fn extract_route_middleware( + root: Node<'_>, + bytes: &[u8], + target: &str, + receiver_accepts: &dyn Fn(&str) -> bool, +) -> Vec { + let mut global: Vec = Vec::new(); + let mut route_chain: Vec = Vec::new(); + walk_for_middleware( + root, + bytes, + target, + receiver_accepts, + &mut global, + &mut route_chain, + ); + global.extend(route_chain); + global +} + +fn walk_for_middleware<'a>( + node: Node<'a>, + bytes: &[u8], + target: &str, + receiver_accepts: &dyn Fn(&str) -> bool, + global: &mut Vec, + route_chain: &mut Vec, +) { + if node.kind() == "call_expression" + && let Some(callee) = node.child_by_field_name("function") + && callee.kind() == "member_expression" + && let Some(object) = callee.child_by_field_name("object") + && let Some(property) = callee.child_by_field_name("property") + && let Some(object_text) = object.utf8_text(bytes).ok() + && let Some(prop_text) = property.utf8_text(bytes).ok() + && receiver_accepts(last_segment(object_text)) + && let Some(args) = node.child_by_field_name("arguments") + { + if prop_text == "use" { + for name in collect_use_arg_names(args, bytes) { + global.push(MiddlewareShape { name }); + } + } else if http_verb_from_method(prop_text).is_some() + && call_args_reference_target(args, bytes, target) + { + for name in collect_chain_middleware_names(args, bytes, target) { + route_chain.push(MiddlewareShape { name }); + } + } + } + let mut cur = node.walk(); + for child in node.children(&mut cur) { + walk_for_middleware(child, bytes, target, receiver_accepts, global, route_chain); + } +} + +/// Pull middleware names from a positional `(, mw1, mw2, …, +/// handler)` arguments node. Skips the leading string-literal path, +/// stops at the named handler reference, and ignores object-literal +/// option arguments (Fastify's `{ schema, preHandler, … }` shape is +/// handled separately by [`collect_options_middleware_names`]). +fn collect_chain_middleware_names(args: Node<'_>, bytes: &[u8], target: &str) -> Vec { + let mut out = Vec::new(); + let mut seen_path_literal = false; + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + match c.kind() { + "string" | "template_string" if !seen_path_literal => { + seen_path_literal = true; + } + "identifier" => { + if let Ok(text) = c.utf8_text(bytes) { + if text == target { + break; + } + out.push(text.to_owned()); + } + } + "member_expression" => { + if let Ok(text) = c.utf8_text(bytes) { + let last = last_segment(text); + if last == target { + break; + } + out.push(last.to_owned()); + } + } + "call_expression" => { + // Inline middleware factory call like `auth({ role: 'admin' })`. + if let Some(fn_node) = c.child_by_field_name("function") + && let Ok(text) = fn_node.utf8_text(bytes) + { + out.push(last_segment(text).to_owned()); + } + } + _ => {} + } + } + out +} + +/// Pull middleware names from a `.use(, [, …])` call. +/// Each positional argument that resolves to an identifier or a call +/// expression contributes one entry; string-named middleware modules +/// (`app.use('/admin', adminRouter)`) skip the path string. +fn collect_use_arg_names(args: Node<'_>, bytes: &[u8]) -> Vec { + let mut out = Vec::new(); + let mut cur = args.walk(); + for c in args.named_children(&mut cur) { + match c.kind() { + "identifier" => { + if let Ok(text) = c.utf8_text(bytes) { + out.push(text.to_owned()); + } + } + "member_expression" => { + if let Ok(text) = c.utf8_text(bytes) { + out.push(last_segment(text).to_owned()); + } + } + "call_expression" => { + if let Some(fn_node) = c.child_by_field_name("function") + && let Ok(text) = fn_node.utf8_text(bytes) + { + out.push(last_segment(text).to_owned()); + } + } + _ => {} + } + } + out +} + /// Parse a Fastify options-object call `fastify.route({ method, url, /// handler })` returning the bound `(method, url)` when the /// `handler:` property references `target`. @@ -629,6 +778,48 @@ mod tests { assert_eq!(path, "/save"); } + #[test] + fn extract_middleware_picks_up_chain_args() { + let src: &[u8] = b"app.post('/save', authz, validate, handler);\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "app"; + let mw = extract_route_middleware(tree.root_node(), src, "handler", &recv); + let names: Vec<_> = mw.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["authz", "validate"]); + } + + #[test] + fn extract_middleware_records_app_use_in_order() { + let src: &[u8] = b"app.use(helmet());\napp.use(logger);\napp.get('/x', handler);\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "app"; + let mw = extract_route_middleware(tree.root_node(), src, "handler", &recv); + let names: Vec<_> = mw.iter().map(|m| m.name.as_str()).collect(); + assert_eq!(names, vec!["helmet", "logger"]); + } + + #[test] + fn extract_middleware_returns_empty_on_no_chain() { + let src: &[u8] = b"app.get('/x', handler);\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "app"; + let mw = extract_route_middleware(tree.root_node(), src, "handler", &recv); + assert!(mw.is_empty()); + } + + #[test] + fn extract_middleware_skips_member_expression_path_alias() { + let src: &[u8] = + b"app.post('/save', mw.csrf, mw.auth, controller.save);\n"; + let tree = parse_js(src); + let recv = |n: &str| n == "app"; + let mw = extract_route_middleware(tree.root_node(), src, "save", &recv); + let names: Vec<_> = mw.iter().map(|m| m.name.as_str()).collect(); + // `controller.save` is the handler; everything before is middleware. + // We record the last segment of each member expression. + assert_eq!(names, vec!["csrf", "auth"]); + } + #[test] fn find_route_registration_matches_fastify_options_object() { let src: &[u8] = diff --git a/src/dynamic/framework/adapters/migration_flyway.rs b/src/dynamic/framework/adapters/migration_flyway.rs new file mode 100644 index 00000000..a0327443 --- /dev/null +++ b/src/dynamic/framework/adapters/migration_flyway.rs @@ -0,0 +1,233 @@ +//! Flyway migration adapter (Java). +//! +//! Fires when the surrounding source declares a Java class extending +//! `BaseJavaMigration` or implementing `JavaMigration` from the +//! `org.flywaydb.core.api.migration` package, and the function under +//! analysis is the canonical `migrate(Context)` entry point or runs +//! JDBC DDL through the context-supplied connection. +//! +//! Notably does NOT fire just because a helper method is named +//! `migrate` in a file that has no Flyway import marker. The +//! source-shape needle plus the entry-name / DDL-callee gate together +//! mirror the Phase 21 binding-stealing audit applied to +//! `migration_rails` and `migration_django`. + +use crate::dynamic::framework::{FrameworkAdapter, FrameworkBinding}; +use crate::evidence::EntryKind; +use crate::summary::FuncSummary; +use crate::symbol::Lang; + +pub struct MigrationFlywayAdapter; + +const ADAPTER_NAME: &str = "migration-flyway"; + +fn callee_is_flyway_ddl(name: &str) -> bool { + let last = name.rsplit_once('.').map(|(_, s)| s).unwrap_or(name); + matches!( + last, + "execute" + | "executeUpdate" + | "executeQuery" + | "executeLargeUpdate" + | "prepareStatement" + | "createStatement" + | "addBatch" + | "executeBatch" + ) +} + +fn source_has_flyway_shape(file_bytes: &[u8]) -> bool { + const NEEDLES: &[&[u8]] = &[ + b"org.flywaydb.core.api.migration.BaseJavaMigration", + b"org.flywaydb.core.api.migration.JavaMigration", + b"org.flywaydb.core.api.migration.Context", + b"extends BaseJavaMigration", + b"implements JavaMigration", + ]; + NEEDLES + .iter() + .any(|n| file_bytes.windows(n.len()).any(|w| w == *n)) +} + +fn name_is_migration_entry(name: &str) -> bool { + matches!(name, "migrate") +} + +/// Pull the version out of the Flyway filename convention. Real +/// Flyway parses the version from the class name (`V1_2_3__Add_users` +/// → `1.2.3`) using the same rule documented at +/// . +/// We approximate by scanning the file bytes for a `class V__` +/// declaration; if missing, return `None` so the verifier can fall +/// back to filename-based version derivation later in the pipeline. +fn extract_version(file_bytes: &[u8]) -> Option { + let text = std::str::from_utf8(file_bytes).unwrap_or(""); + for marker in ["class V", "public class V"] { + if let Some(idx) = text.find(marker) { + let after = &text[idx + marker.len()..]; + if let Some(sep) = after.find("__") { + let raw = &after[..sep]; + let normalised: String = raw + .chars() + .map(|c| if c == '_' { '.' } else { c }) + .collect(); + if !normalised.is_empty() + && normalised + .chars() + .all(|c| c.is_ascii_digit() || c == '.') + { + return Some(normalised); + } + } + } + } + None +} + +impl FrameworkAdapter for MigrationFlywayAdapter { + fn name(&self) -> &'static str { + ADAPTER_NAME + } + + fn lang(&self) -> Lang { + Lang::Java + } + + fn detect( + &self, + summary: &FuncSummary, + _ast: tree_sitter::Node<'_>, + file_bytes: &[u8], + ) -> Option { + let has_shape = source_has_flyway_shape(file_bytes); + let name_matches = name_is_migration_entry(&summary.name); + let body_runs_ddl = super::any_callee_matches(summary, callee_is_flyway_ddl); + let binds = has_shape && (name_matches || body_runs_ddl); + if !binds { + return None; + } + Some(FrameworkBinding { + adapter: ADAPTER_NAME.to_owned(), + kind: EntryKind::Migration { + version: extract_version(file_bytes), + }, + route: None, + request_params: Vec::new(), + response_writer: None, + middleware: Vec::new(), + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + 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() + } + + #[test] + fn fires_on_base_java_migration_subclass() { + let src: &[u8] = b"import org.flywaydb.core.api.migration.BaseJavaMigration;\n\ + import org.flywaydb.core.api.migration.Context;\n\ + public class V1_2_3__Add_users extends BaseJavaMigration {\n\ + public void migrate(Context context) throws Exception { }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "migrate".into(), + ..Default::default() + }; + let binding = MigrationFlywayAdapter + .detect(&summary, tree.root_node(), src) + .expect("flyway migration binds"); + assert_eq!(binding.adapter, "migration-flyway"); + if let EntryKind::Migration { version } = binding.kind { + assert_eq!(version.as_deref(), Some("1.2.3")); + } else { + panic!("expected Migration entry kind"); + } + } + + #[test] + fn fires_when_implementing_java_migration_interface() { + let src: &[u8] = b"import org.flywaydb.core.api.migration.JavaMigration;\n\ + public class Boot implements JavaMigration {\n\ + public void migrate(Object ctx) { }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "migrate".into(), + ..Default::default() + }; + assert!( + MigrationFlywayAdapter + .detect(&summary, tree.root_node(), src) + .is_some(), + "interface-based Flyway migration must bind", + ); + } + + #[test] + fn skips_helper_named_migrate_without_flyway_import() { + let src: &[u8] = b"public class Helper {\n\ + public void migrate(Object ctx) { }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "migrate".into(), + ..Default::default() + }; + assert!( + MigrationFlywayAdapter + .detect(&summary, tree.root_node(), src) + .is_none(), + "helper named `migrate` without Flyway import must not bind", + ); + } + + #[test] + fn skips_unrelated_method_in_flyway_file() { + let src: &[u8] = b"import org.flywaydb.core.api.migration.BaseJavaMigration;\n\ + public class V1__Init extends BaseJavaMigration {\n\ + public void helper() { }\n\ + public void migrate(Object ctx) { }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "helper".into(), + ..Default::default() + }; + assert!( + MigrationFlywayAdapter + .detect(&summary, tree.root_node(), src) + .is_none(), + "helper method that does not run DDL must not bind even inside a Flyway file", + ); + } + + #[test] + fn extracts_dotted_version_from_filename_class() { + let src: &[u8] = b"import org.flywaydb.core.api.migration.BaseJavaMigration;\n\ + public class V2_0__Seed extends BaseJavaMigration {\n\ + public void migrate(Object ctx) { }\n\ + }\n"; + let tree = parse_java(src); + let summary = FuncSummary { + name: "migrate".into(), + ..Default::default() + }; + let binding = MigrationFlywayAdapter + .detect(&summary, tree.root_node(), src) + .expect("binds"); + if let EntryKind::Migration { version } = binding.kind { + assert_eq!(version.as_deref(), Some("2.0")); + } else { + panic!("expected Migration entry kind"); + } + } +} diff --git a/src/dynamic/framework/adapters/mod.rs b/src/dynamic/framework/adapters/mod.rs index be75f4bb..49d010fe 100644 --- a/src/dynamic/framework/adapters/mod.rs +++ b/src/dynamic/framework/adapters/mod.rs @@ -53,6 +53,7 @@ pub mod middleware_rails; pub mod middleware_spring; pub mod migration_django; pub mod migration_flask; +pub mod migration_flyway; pub mod migration_laravel; pub mod migration_prisma; pub mod migration_rails; @@ -156,6 +157,7 @@ pub use middleware_rails::MiddlewareRailsAdapter; pub use middleware_spring::MiddlewareSpringAdapter; pub use migration_django::MigrationDjangoAdapter; pub use migration_flask::MigrationFlaskAdapter; +pub use migration_flyway::MigrationFlywayAdapter; pub use migration_laravel::MigrationLaravelAdapter; pub use migration_prisma::MigrationPrismaAdapter; pub use migration_rails::MigrationRailsAdapter; diff --git a/src/dynamic/framework/mod.rs b/src/dynamic/framework/mod.rs index 0a09cf44..7caf36a1 100644 --- a/src/dynamic/framework/mod.rs +++ b/src/dynamic/framework/mod.rs @@ -220,6 +220,7 @@ mod tests { // / `Middleware` / `Migration` — distributed across the // language slices. Per-lang deltas vs the Phase 20 baseline: // Java: +2 (ScheduledQuartz, MiddlewareSpring) 14 → 16 + // +1 follow-up (MigrationFlyway) 16 → 17 // Php: +2 (MiddlewareLaravel, MigrationLaravel) 10 → 12 // Python: +7 (GraphqlGraphene, MiddlewareDjango, // MigrationDjango, MigrationFlask, @@ -237,8 +238,8 @@ mod tests { let java_registered = registry::adapters_for(Lang::Java); assert_eq!( java_registered.len(), - 16, - "Java must have Phase 20 baseline (14) + M.3 Quartz/Spring-middleware (2)", + 17, + "Java must have Phase 20 baseline (14) + M.3 Quartz/Spring-middleware (2) + Flyway (1)", ); for adapter in java_registered { assert_eq!(adapter.lang(), Lang::Java); diff --git a/src/dynamic/framework/registry.rs b/src/dynamic/framework/registry.rs index 99cd7e08..e38a399a 100644 --- a/src/dynamic/framework/registry.rs +++ b/src/dynamic/framework/registry.rs @@ -66,6 +66,7 @@ static JAVA: &[&dyn FrameworkAdapter] = &[ &super::adapters::KafkaJavaAdapter, &super::adapters::LdapSpringAdapter, &super::adapters::MiddlewareSpringAdapter, + &super::adapters::MigrationFlywayAdapter, &super::adapters::RabbitJavaAdapter, &super::adapters::RedirectJavaAdapter, &super::adapters::ScheduledQuartzAdapter,