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<String,String> injections.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-13 21:41:16 +02:00
parent 69361e5a60
commit 44497afd32
2 changed files with 80 additions and 3 deletions

View File

@@ -62,9 +62,9 @@ public class InjectionPointAnalyzer {
try { try {
IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i); IAnnotationBinding[] paramAnns = method.getParameterAnnotations(i);
String paramQual = getQualifierValue(paramAnns); String paramQual = getQualifierValue(paramAnns);
if (paramQual != null && paramType.getErasure().isEqualTo(binding.getType().getErasure())) { if (paramQual != null
// For setters, verify it roughly matches the field name or just rely on type. && paramType.getErasure().isEqualTo(binding.getType().getErasure())
// We will rely on type equality for now as a heuristic. && parameterMatchesField(method, i, binding.getName())) {
return paramQual; return paramQual;
} }
} catch (Exception e) { } catch (Exception e) {
@@ -90,4 +90,29 @@ public class InjectionPointAnalyzer {
} }
return null; 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;
}
} }

View File

@@ -66,6 +66,58 @@ class SpringInjectionRoutingTest {
chain, "com.example.OrderStateMachineConfig", context)).isFalse(); 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<String, String> orderMachine;
private final StateMachine<String, String> paymentMachine;
public PaymentService(StateMachine<String, String> orderMachine,
@Qualifier("paymentStateMachine") StateMachine<String, String> 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<String, String> {}
@org.springframework.context.annotation.Configuration
@org.springframework.statemachine.config.EnableStateMachine(name = "orderStateMachine")
class OrderStateMachineConfig
extends org.springframework.statemachine.config.StateMachineConfigurerAdapter<String, String> {}
""");
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 @Test
void shouldFailClosedForAmbiguousStringStateMachineWithoutQualifier(@TempDir Path tempDir) throws Exception { void shouldFailClosedForAmbiguousStringStateMachineWithoutQualifier(@TempDir Path tempDir) throws Exception {
Files.writeString(tempDir.resolve("OrderService.java"), """ Files.writeString(tempDir.resolve("OrderService.java"), """