From 44497afd328608392618d7c3f5a8183056b12eb3 Mon Sep 17 00:00:00 2001 From: Kamil Patryk Kozakowski Date: Mon, 13 Jul 2026 21:41:16 +0200 Subject: [PATCH] Avoid borrowing @Qualifier from unrelated injection parameters Require parameter-name or setter-name agreement when inferring a field qualifier from constructor/autowired parameter annotations, preventing false-positive routing for multi-bean StateMachine injections. Co-authored-by: Cursor --- .../spring/InjectionPointAnalyzer.java | 31 +++++++++-- .../routing/SpringInjectionRoutingTest.java | 52 +++++++++++++++++++ 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/spring/InjectionPointAnalyzer.java b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/spring/InjectionPointAnalyzer.java index b04ac05..bbfa856 100644 --- a/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/spring/InjectionPointAnalyzer.java +++ b/state_machine_exporter/src/main/java/click/kamil/springstatemachineexporter/analysis/spring/InjectionPointAnalyzer.java @@ -62,9 +62,9 @@ public class InjectionPointAnalyzer { try { IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i); String paramQual = getQualifierValue(paramAnns); - if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) { - // For setters, verify it roughly matches the field name or just rely on type. - // We will rely on type equality for now as a heuristic. + if (paramQual != null + && paramType.getErasure().isEqualTo(binding.getType().getErasure()) + && parameterMatchesField(method, i, binding.getName())) { return paramQual; } } catch (Exception e) { @@ -90,4 +90,29 @@ public class InjectionPointAnalyzer { } return null; } + + private static boolean parameterMatchesField( + org.eclipse.jdt.core.dom.IMethodBinding method, + int parameterIndex, + String fieldName) { + if (method == null || fieldName == null || fieldName.isBlank() || parameterIndex < 0) { + return false; + } + String[] names; + try { + names = method.getParameterNames(); + } catch (Exception ignored) { + names = null; + } + if (names != null && parameterIndex < names.length) { + return fieldName.equals(names[parameterIndex]); + } + // If parameter names aren't available, only accept clear setter-like methods. + String methodName = method.getName(); + if (methodName != null && methodName.startsWith("set") && method.getParameterTypes().length == 1) { + String expected = "set" + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); + return expected.equals(methodName); + } + return false; + } } diff --git a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java index be9bbc9..5a1e7cb 100644 --- a/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java +++ b/state_machine_exporter/src/test/java/click/kamil/springstatemachineexporter/analysis/enricher/routing/SpringInjectionRoutingTest.java @@ -66,6 +66,58 @@ class SpringInjectionRoutingTest { chain, "com.example.OrderStateMachineConfig", context)).isFalse(); } + @Test + void shouldNotBorrowConstructorQualifierFromUnrelatedParameter(@TempDir Path tempDir) throws Exception { + Files.writeString(tempDir.resolve("PaymentService.java"), """ + package com.example; + import org.springframework.beans.factory.annotation.Qualifier; + import org.springframework.statemachine.StateMachine; + import org.springframework.stereotype.Service; + @Service + public class PaymentService { + private final StateMachine orderMachine; + private final StateMachine paymentMachine; + public PaymentService(StateMachine orderMachine, + @Qualifier("paymentStateMachine") StateMachine paymentMachine) { + this.orderMachine = orderMachine; + this.paymentMachine = paymentMachine; + } + public void captureOnOrder() { + orderMachine.sendEvent("CAPTURE"); + } + } + @org.springframework.context.annotation.Configuration + @org.springframework.statemachine.config.EnableStateMachine(name = "paymentStateMachine") + class PaymentStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + @org.springframework.context.annotation.Configuration + @org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine") + class OrderStateMachineConfig + extends org.springframework.statemachine.config.StateMachineConfigurerAdapter {} + """); + + CodebaseContext context = new CodebaseContext(); + context.setResolveBindings(true); + context.scan(tempDir); + + HeuristicBeanResolutionEngine engine = new HeuristicBeanResolutionEngine(); + CallChain chain = CallChain.builder() + .triggerPoint(TriggerPoint.builder() + .className("com.example.PaymentService") + .methodName("captureOnOrder") + .lineNumber(18) + .event("CAPTURE") + .eventTypeFqn("java.lang.String") + .stateTypeFqn("java.lang.String") + .build()) + .methodChain(List.of("com.example.PaymentService.captureOnOrder()")) + .build(); + + // Both are String,String machines and receiver has no explicit qualifier -> should fail closed. + assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.PaymentStateMachineConfig", context)).isFalse(); + assertThat(engine.isRoutedToCorrectMachine(chain, "com.example.OrderStateMachineConfig", context)).isFalse(); + } + @Test void shouldFailClosedForAmbiguousStringStateMachineWithoutQualifier(@TempDir Path tempDir) throws Exception { Files.writeString(tempDir.resolve("OrderService.java"), """