Skip to content

Commit 4b29156

Browse files
lukaszlenartclaude
andauthored
fix(convention): WW-4421 detect duplicate @action names when execute() is annotated (#1590)
The duplicate @action name detection in PackageBasedActionConfigBuilder was embedded inside a conditional block that only ran when execute() was NOT annotated with @action. This meant two methods could map to the same action name silently when execute() had an @action annotation, with one overwriting the other non-deterministically. Extract the duplicate check to run unconditionally before the conditional block, so it applies to all annotated methods regardless of whether execute() is annotated. Backport of #1579 from Struts 7.x to 6.x. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude <noreply@anthropic.com>
1 parent 18d6e77 commit 4b29156

5 files changed

Lines changed: 234 additions & 37 deletions

File tree

plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java

Lines changed: 48 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -716,42 +716,28 @@ protected void buildConfiguration(Set<Class<?>> classes) {
716716

717717
// Verify that the annotations have no errors and also determine if the default action
718718
// configuration should still be built or not.
719-
Map<String, List<Action>> map = getActionAnnotations(actionClass);
720-
Set<String> actionNames = new HashSet<>();
719+
Map<String, List<Action>> actionAnnotationsByMethod = getActionAnnotations(actionClass);
721720
boolean hasDefaultMethod = ReflectionTools.containsMethod(actionClass, DEFAULT_METHOD);
722-
if (!map.containsKey(DEFAULT_METHOD)
723-
&& hasDefaultMethod
724-
&& actionAnnotation == null && actionsAnnotation == null
725-
&& (alwaysMapExecute || map.isEmpty())) {
726-
boolean found = false;
727-
for (List<Action> actions : map.values()) {
728-
for (Action action : actions) {
729-
730-
// Check if there are duplicate action names in the annotations.
731-
String actionName = action.value().equals(Action.DEFAULT_VALUE) ? defaultActionName : action.value();
732-
if (actionNames.contains(actionName)) {
733-
throw new ConfigurationException("The action class [" + actionClass +
734-
"] contains two methods with an action name annotation whose value " +
735-
"is the same (they both might be empty as well).");
736-
} else {
737-
actionNames.add(actionName);
738-
}
739-
740-
// Check this annotation is the default action
741-
if (action.value().equals(Action.DEFAULT_VALUE)) {
742-
found = true;
743-
}
721+
722+
// Check for duplicate action names across all annotated methods
723+
Set<String> actionNames = new HashSet<>();
724+
for (List<Action> actions : actionAnnotationsByMethod.values()) {
725+
for (Action action : actions) {
726+
String actionName = action.value().equals(Action.DEFAULT_VALUE) ? defaultActionName : action.value();
727+
if (!actionNames.add(actionName)) {
728+
throw new ConfigurationException("The action class [" + actionClass +
729+
"] contains two methods with an action name annotation whose value " +
730+
"is the same (they both might be empty as well).");
744731
}
745732
}
733+
}
746734

747-
// Build the default
748-
if (!found) {
749-
createActionConfig(defaultPackageConfig, actionClass, defaultActionName, DEFAULT_METHOD, null, allowedMethods);
750-
}
735+
if (shouldMapDefaultExecuteMethod(actionAnnotationsByMethod, hasDefaultMethod, actionAnnotation, actionsAnnotation)) {
736+
createActionConfig(defaultPackageConfig, actionClass, defaultActionName, DEFAULT_METHOD, null, allowedMethods);
751737
}
752738

753739
// Build the actions for the annotations
754-
for (Map.Entry<String, List<Action>> entry : map.entrySet()) {
740+
for (Map.Entry<String, List<Action>> entry : actionAnnotationsByMethod.entrySet()) {
755741
String method = entry.getKey();
756742
List<Action> actions = entry.getValue();
757743
for (Action action : actions) {
@@ -767,7 +753,7 @@ protected void buildConfiguration(Set<Class<?>> classes) {
767753

768754
// some actions will not have any @Action or a default method, like the rest actions
769755
// where the action mapper is the one that finds the right method at runtime
770-
if (map.isEmpty() && mapAllMatches && actionAnnotation == null && actionsAnnotation == null) {
756+
if (actionAnnotationsByMethod.isEmpty() && mapAllMatches && actionAnnotation == null && actionsAnnotation == null) {
771757
createActionConfig(defaultPackageConfig, actionClass, defaultActionName, null, actionAnnotation, allowedMethods);
772758
}
773759

@@ -818,6 +804,38 @@ protected boolean cannotInstantiate(Class<?> actionClass) {
818804
(actionClass.getModifiers() & Modifier.ABSTRACT) != 0 || actionClass.isAnonymousClass();
819805
}
820806

807+
/**
808+
* Determines whether the default {@code execute()} method should be automatically mapped as an action.
809+
* This is the case when no explicit mapping exists for the default method, the class has an execute method,
810+
* there are no class-level @Action/@Actions annotations, and no other method already maps to the default action name.
811+
*
812+
* @param actionAnnotationsByMethod the map of method names to their @Action annotations
813+
* @param hasDefaultMethod whether the action class has an execute() method
814+
* @param classAction the class-level @Action annotation, or null
815+
* @param classActions the class-level @Actions annotation, or null
816+
* @return true if the default execute method should be mapped
817+
*/
818+
protected boolean shouldMapDefaultExecuteMethod(Map<String, List<Action>> actionAnnotationsByMethod, boolean hasDefaultMethod,
819+
Action classAction, Actions classActions) {
820+
if (actionAnnotationsByMethod.containsKey(DEFAULT_METHOD) || !hasDefaultMethod) {
821+
return false;
822+
}
823+
if (classAction != null || classActions != null) {
824+
return false;
825+
}
826+
if (!alwaysMapExecute && !actionAnnotationsByMethod.isEmpty()) {
827+
return false;
828+
}
829+
for (List<Action> actions : actionAnnotationsByMethod.values()) {
830+
for (Action action : actions) {
831+
if (action.value().equals(Action.DEFAULT_VALUE)) {
832+
return false;
833+
}
834+
}
835+
}
836+
return true;
837+
}
838+
821839
/**
822840
* Determines the namespace(s) for the action based on the action class. If there is a {@link Namespace}
823841
* annotation on the class (including parent classes) or on the package that the class is in, than

plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import com.opensymphony.xwork2.ObjectFactory;
2626
import com.opensymphony.xwork2.Result;
2727
import com.opensymphony.xwork2.config.Configuration;
28+
import com.opensymphony.xwork2.config.ConfigurationException;
2829
import com.opensymphony.xwork2.config.entities.ActionConfig;
2930
import com.opensymphony.xwork2.config.entities.ExceptionMappingConfig;
3031
import com.opensymphony.xwork2.config.entities.InterceptorConfig;
@@ -131,6 +132,103 @@ public void setUp() throws Exception {
131132
.bind();
132133
}
133134

135+
/**
136+
* Tests that duplicate @Action name detection works when execute() is annotated with @Action.
137+
* Before the fix for WW-4421, the duplicate check was inside a conditional block that was
138+
* skipped when execute() had an @Action annotation, allowing duplicate action names silently.
139+
*
140+
* @see <a href="https://issues.apache.org/jira/browse/WW-4421">WW-4421</a>
141+
*/
142+
public void testDuplicateActionNameWithAnnotatedExecute() {
143+
ResultTypeConfig defaultResult = new ResultTypeConfig.Builder("dispatcher",
144+
ServletDispatcherResult.class.getName()).defaultResultParam("location").build();
145+
PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher",
146+
new ResultTypeConfig[]{defaultResult}, null, null, null, true);
147+
148+
final DummyContainer mockContainer = new DummyContainer();
149+
Configuration configuration = new DefaultConfiguration() {
150+
@Override
151+
public Container getContainer() {
152+
return mockContainer;
153+
}
154+
};
155+
configuration.addPackageConfig("struts-default", strutsDefault);
156+
157+
ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", "-");
158+
ObjectFactory of = new ObjectFactory();
159+
of.setContainer(mockContainer);
160+
161+
mockContainer.setActionNameBuilder(actionNameBuilder);
162+
mockContainer.setConventionsService(new ConventionsServiceImpl(""));
163+
164+
PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(
165+
configuration, mockContainer, of, "false", "struts-default", "false");
166+
builder.setActionPackages("org.apache.struts2.convention.duplicate.annotatedexecute");
167+
builder.setActionSuffix("Action");
168+
169+
DefaultFileManagerFactory fileManagerFactory = new DefaultFileManagerFactory();
170+
fileManagerFactory.setContainer(ActionContext.getContext().getContainer());
171+
fileManagerFactory.setFileManager(new DefaultFileManager());
172+
builder.setFileManagerFactory(fileManagerFactory);
173+
builder.setProviderAllowlist(new ProviderAllowlist());
174+
175+
try {
176+
builder.buildActionConfigs();
177+
fail("Expected ConfigurationException for duplicate action names");
178+
} catch (ConfigurationException e) {
179+
assertTrue("Exception message should mention duplicate action names",
180+
e.getMessage().contains("two methods with an action name annotation whose value is the same"));
181+
}
182+
}
183+
184+
/**
185+
* Tests that duplicate @Action name detection works when execute() is NOT annotated with @Action.
186+
* This is a regression guard for WW-4421 — this case was already detected before the fix.
187+
*
188+
* @see <a href="https://issues.apache.org/jira/browse/WW-4421">WW-4421</a>
189+
*/
190+
public void testDuplicateActionNameWithoutAnnotatedExecute() {
191+
ResultTypeConfig defaultResult = new ResultTypeConfig.Builder("dispatcher",
192+
ServletDispatcherResult.class.getName()).defaultResultParam("location").build();
193+
PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher",
194+
new ResultTypeConfig[]{defaultResult}, null, null, null, true);
195+
196+
final DummyContainer mockContainer = new DummyContainer();
197+
Configuration configuration = new DefaultConfiguration() {
198+
@Override
199+
public Container getContainer() {
200+
return mockContainer;
201+
}
202+
};
203+
configuration.addPackageConfig("struts-default", strutsDefault);
204+
205+
ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", "-");
206+
ObjectFactory of = new ObjectFactory();
207+
of.setContainer(mockContainer);
208+
209+
mockContainer.setActionNameBuilder(actionNameBuilder);
210+
mockContainer.setConventionsService(new ConventionsServiceImpl(""));
211+
212+
PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(
213+
configuration, mockContainer, of, "false", "struts-default", "false");
214+
builder.setActionPackages("org.apache.struts2.convention.duplicate.unannotatedexecute");
215+
builder.setActionSuffix("Action");
216+
217+
DefaultFileManagerFactory fileManagerFactory = new DefaultFileManagerFactory();
218+
fileManagerFactory.setContainer(ActionContext.getContext().getContainer());
219+
fileManagerFactory.setFileManager(new DefaultFileManager());
220+
builder.setFileManagerFactory(fileManagerFactory);
221+
builder.setProviderAllowlist(new ProviderAllowlist());
222+
223+
try {
224+
builder.buildActionConfigs();
225+
fail("Expected ConfigurationException for duplicate action names");
226+
} catch (ConfigurationException e) {
227+
assertTrue("Exception message should mention duplicate action names",
228+
e.getMessage().contains("two methods with an action name annotation whose value is the same"));
229+
}
230+
}
231+
134232
public void testActionPackages() throws MalformedURLException {
135233
run("org.apache.struts2.convention.actions", null, null);
136234
}
@@ -352,7 +450,7 @@ private void run(String actionPackages, String packageLocators, String excludePa
352450
expect(resultMapBuilder.build(GlobalResultAction.class, null, "global-result", globalResultPkg)).andReturn(results);
353451
expect(resultMapBuilder.build(GlobalResultOverrideAction.class, null, "global-result-override", globalResultPkg)).andReturn(results);
354452
expect(resultMapBuilder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "execute", Action.class), "action-level-results-names", resultPkg)).andReturn(results);
355-
expect(resultMapBuilder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class), "action-level-results-names", resultPkg)).andReturn(results);
453+
expect(resultMapBuilder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class), "action-level-results-names-noname", resultPkg)).andReturn(results);
356454

357455
/* org.apache.struts2.convention.actions.resultpath */
358456
expect(resultMapBuilder.build(ClassLevelResultPathAction.class, null, "class-level-result-path", resultPathPkg)).andReturn(results);
@@ -662,11 +760,12 @@ public Container getContainer() {
662760
pkgConfig = configuration.getPackageConfig("org.apache.struts2.convention.actions.result#struts-default#/result");
663761
assertNotNull(pkgConfig);
664762
checkSmiValue(pkgConfig, strutsDefault, isSmiInheritanceEnabled);
665-
assertEquals(7, pkgConfig.getActionConfigs().size());
763+
assertEquals(8, pkgConfig.getActionConfigs().size());
666764
verifyActionConfig(pkgConfig, "class-level-result", ClassLevelResultAction.class, "execute", pkgConfig.getName());
667765
verifyActionConfig(pkgConfig, "class-level-results", ClassLevelResultsAction.class, "execute", pkgConfig.getName());
668766
verifyActionConfig(pkgConfig, "action-level-result", ActionLevelResultAction.class, "execute", pkgConfig.getName());
669767
verifyActionConfig(pkgConfig, "action-level-results", ActionLevelResultsAction.class, "execute", pkgConfig.getName());
768+
verifyActionConfig(pkgConfig, "action-level-results-names-noname", ActionLevelResultsNamesAction.class, "noname", pkgConfig.getName());
670769
verifyActionConfig(pkgConfig, "inherited-result-extends", InheritedResultExtends.class, "execute", pkgConfig.getName());
671770

672771
/* org.apache.struts2.convention.actions.resultpath */

plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,16 +28,16 @@
2828
*/
2929
public class ActionLevelResultsNamesAction {
3030
@Action(results = {
31-
@Result(name={"error", "input"}, location="error.jsp"),
32-
@Result(name="success", location="/WEB-INF/location/namespace/action-success.jsp"),
33-
@Result(name="failure", location="/WEB-INF/location/namespace/action-failure.jsp")
31+
@Result(name = {"error", "input"}, location = "error.jsp"),
32+
@Result(name = "success", location = "/WEB-INF/location/namespace/action-success.jsp"),
33+
@Result(name = "failure", location = "/WEB-INF/location/namespace/action-failure.jsp")
3434
})
3535
public String execute() {
3636
return null;
3737
}
3838

39-
@Action(results = {
40-
@Result(location="/WEB-INF/location/namespace/action-success.jsp")
39+
@Action(value = "action-level-results-names-noname", results = {
40+
@Result(location = "/WEB-INF/location/namespace/action-success.jsp")
4141
})
4242
public String noname() {
4343
return null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.struts2.convention.duplicate.annotatedexecute;
20+
21+
import org.apache.struts2.convention.annotation.Action;
22+
23+
/**
24+
* Test action with duplicate @Action names where execute() is annotated.
25+
* This is the case that was previously not detected (WW-4421).
26+
*/
27+
public class DuplicateActionNameWithExecuteAnnotationAction {
28+
29+
@Action("duplicate")
30+
public String execute() {
31+
return "success";
32+
}
33+
34+
@Action("duplicate")
35+
public String anotherMethod() {
36+
return "success";
37+
}
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one
3+
* or more contributor license agreements. See the NOTICE file
4+
* distributed with this work for additional information
5+
* regarding copyright ownership. The ASF licenses this file
6+
* to you under the Apache License, Version 2.0 (the
7+
* "License"); you may not use this file except in compliance
8+
* with the License. You may obtain a copy of the License at
9+
*
10+
* http://www.apache.org/licenses/LICENSE-2.0
11+
*
12+
* Unless required by applicable law or agreed to in writing,
13+
* software distributed under the License is distributed on an
14+
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
15+
* KIND, either express or implied. See the License for the
16+
* specific language governing permissions and limitations
17+
* under the License.
18+
*/
19+
package org.apache.struts2.convention.duplicate.unannotatedexecute;
20+
21+
import org.apache.struts2.convention.annotation.Action;
22+
23+
/**
24+
* Test action with duplicate @Action names where execute() is NOT annotated.
25+
* This is the case that was already detected before the fix (regression guard for WW-4421).
26+
*/
27+
public class DuplicateActionNameWithoutExecuteAnnotationAction {
28+
29+
public String execute() {
30+
return "success";
31+
}
32+
33+
@Action("duplicate")
34+
public String method1() {
35+
return "success";
36+
}
37+
38+
@Action("duplicate")
39+
public String method2() {
40+
return "success";
41+
}
42+
}

0 commit comments

Comments
 (0)