This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch fix/WW-4421-duplicate-action-name-detection in repository https://gitbox.apache.org/repos/asf/struts.git
commit c2674aad19c337c7d106695bebc9054f66f78f7f Author: Lukasz Lenart <[email protected]> AuthorDate: Sat Feb 21 14:54:20 2026 +0100 fix(convention): WW-4421 detect duplicate @Action names when execute() is annotated 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 apache/struts#1579 from Struts 7.x to 6.x. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> --- .../PackageBasedActionConfigBuilder.java | 78 ++++++++++------ .../PackageBasedActionConfigBuilderTest.java | 103 ++++++++++++++++++++- .../result/ActionLevelResultsNamesAction.java | 10 +- ...cateActionNameWithExecuteAnnotationAction.java} | 27 ++---- ...eActionNameWithoutExecuteAnnotationAction.java} | 31 +++---- 5 files changed, 178 insertions(+), 71 deletions(-) diff --git a/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java b/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java index 71331d4ef..c4b94376d 100644 --- a/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java +++ b/plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java @@ -716,42 +716,28 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { // Verify that the annotations have no errors and also determine if the default action // configuration should still be built or not. - Map<String, List<Action>> map = getActionAnnotations(actionClass); - Set<String> actionNames = new HashSet<>(); + Map<String, List<Action>> actionAnnotationsByMethod = getActionAnnotations(actionClass); boolean hasDefaultMethod = ReflectionTools.containsMethod(actionClass, DEFAULT_METHOD); - if (!map.containsKey(DEFAULT_METHOD) - && hasDefaultMethod - && actionAnnotation == null && actionsAnnotation == null - && (alwaysMapExecute || map.isEmpty())) { - boolean found = false; - for (List<Action> actions : map.values()) { - for (Action action : actions) { - - // Check if there are duplicate action names in the annotations. - String actionName = action.value().equals(Action.DEFAULT_VALUE) ? defaultActionName : action.value(); - if (actionNames.contains(actionName)) { - throw new ConfigurationException("The action class [" + actionClass + - "] contains two methods with an action name annotation whose value " + - "is the same (they both might be empty as well)."); - } else { - actionNames.add(actionName); - } - - // Check this annotation is the default action - if (action.value().equals(Action.DEFAULT_VALUE)) { - found = true; - } + + // Check for duplicate action names across all annotated methods + Set<String> actionNames = new HashSet<>(); + for (List<Action> actions : actionAnnotationsByMethod.values()) { + for (Action action : actions) { + String actionName = action.value().equals(Action.DEFAULT_VALUE) ? defaultActionName : action.value(); + if (!actionNames.add(actionName)) { + throw new ConfigurationException("The action class [" + actionClass + + "] contains two methods with an action name annotation whose value " + + "is the same (they both might be empty as well)."); } } + } - // Build the default - if (!found) { - createActionConfig(defaultPackageConfig, actionClass, defaultActionName, DEFAULT_METHOD, null, allowedMethods); - } + if (shouldMapDefaultExecuteMethod(actionAnnotationsByMethod, hasDefaultMethod, actionAnnotation, actionsAnnotation)) { + createActionConfig(defaultPackageConfig, actionClass, defaultActionName, DEFAULT_METHOD, null, allowedMethods); } // Build the actions for the annotations - for (Map.Entry<String, List<Action>> entry : map.entrySet()) { + for (Map.Entry<String, List<Action>> entry : actionAnnotationsByMethod.entrySet()) { String method = entry.getKey(); List<Action> actions = entry.getValue(); for (Action action : actions) { @@ -767,7 +753,7 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { // some actions will not have any @Action or a default method, like the rest actions // where the action mapper is the one that finds the right method at runtime - if (map.isEmpty() && mapAllMatches && actionAnnotation == null && actionsAnnotation == null) { + if (actionAnnotationsByMethod.isEmpty() && mapAllMatches && actionAnnotation == null && actionsAnnotation == null) { createActionConfig(defaultPackageConfig, actionClass, defaultActionName, null, actionAnnotation, allowedMethods); } @@ -818,6 +804,38 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { (actionClass.getModifiers() & Modifier.ABSTRACT) != 0 || actionClass.isAnonymousClass(); } + /** + * Determines whether the default {@code execute()} method should be automatically mapped as an action. + * This is the case when no explicit mapping exists for the default method, the class has an execute method, + * there are no class-level @Action/@Actions annotations, and no other method already maps to the default action name. + * + * @param actionAnnotationsByMethod the map of method names to their @Action annotations + * @param hasDefaultMethod whether the action class has an execute() method + * @param classAction the class-level @Action annotation, or null + * @param classActions the class-level @Actions annotation, or null + * @return true if the default execute method should be mapped + */ + protected boolean shouldMapDefaultExecuteMethod(Map<String, List<Action>> actionAnnotationsByMethod, boolean hasDefaultMethod, + Action classAction, Actions classActions) { + if (actionAnnotationsByMethod.containsKey(DEFAULT_METHOD) || !hasDefaultMethod) { + return false; + } + if (classAction != null || classActions != null) { + return false; + } + if (!alwaysMapExecute && !actionAnnotationsByMethod.isEmpty()) { + return false; + } + for (List<Action> actions : actionAnnotationsByMethod.values()) { + for (Action action : actions) { + if (action.value().equals(Action.DEFAULT_VALUE)) { + return false; + } + } + } + return true; + } + /** * Determines the namespace(s) for the action based on the action class. If there is a {@link Namespace} * annotation on the class (including parent classes) or on the package that the class is in, than diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java index b6a9de4be..69d3324cd 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java @@ -25,6 +25,7 @@ import com.opensymphony.xwork2.FileManagerFactory; import com.opensymphony.xwork2.ObjectFactory; import com.opensymphony.xwork2.Result; import com.opensymphony.xwork2.config.Configuration; +import com.opensymphony.xwork2.config.ConfigurationException; import com.opensymphony.xwork2.config.entities.ActionConfig; import com.opensymphony.xwork2.config.entities.ExceptionMappingConfig; import com.opensymphony.xwork2.config.entities.InterceptorConfig; @@ -131,6 +132,103 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { .bind(); } + /** + * Tests that duplicate @Action name detection works when execute() is annotated with @Action. + * Before the fix for WW-4421, the duplicate check was inside a conditional block that was + * skipped when execute() had an @Action annotation, allowing duplicate action names silently. + * + * @see <a href="https://issues.apache.org/jira/browse/WW-4421">WW-4421</a> + */ + public void testDuplicateActionNameWithAnnotatedExecute() { + ResultTypeConfig defaultResult = new ResultTypeConfig.Builder("dispatcher", + ServletDispatcherResult.class.getName()).defaultResultParam("location").build(); + PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher", + new ResultTypeConfig[]{defaultResult}, null, null, null, true); + + final DummyContainer mockContainer = new DummyContainer(); + Configuration configuration = new DefaultConfiguration() { + @Override + public Container getContainer() { + return mockContainer; + } + }; + configuration.addPackageConfig("struts-default", strutsDefault); + + ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", "-"); + ObjectFactory of = new ObjectFactory(); + of.setContainer(mockContainer); + + mockContainer.setActionNameBuilder(actionNameBuilder); + mockContainer.setConventionsService(new ConventionsServiceImpl("")); + + PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( + configuration, mockContainer, of, "false", "struts-default", "false"); + builder.setActionPackages("org.apache.struts2.convention.duplicate.annotatedexecute"); + builder.setActionSuffix("Action"); + + DefaultFileManagerFactory fileManagerFactory = new DefaultFileManagerFactory(); + fileManagerFactory.setContainer(ActionContext.getContext().getContainer()); + fileManagerFactory.setFileManager(new DefaultFileManager()); + builder.setFileManagerFactory(fileManagerFactory); + builder.setProviderAllowlist(new ProviderAllowlist()); + + try { + builder.buildActionConfigs(); + fail("Expected ConfigurationException for duplicate action names"); + } catch (ConfigurationException e) { + assertTrue("Exception message should mention duplicate action names", + e.getMessage().contains("two methods with an action name annotation whose value is the same")); + } + } + + /** + * Tests that duplicate @Action name detection works when execute() is NOT annotated with @Action. + * This is a regression guard for WW-4421 — this case was already detected before the fix. + * + * @see <a href="https://issues.apache.org/jira/browse/WW-4421">WW-4421</a> + */ + public void testDuplicateActionNameWithoutAnnotatedExecute() { + ResultTypeConfig defaultResult = new ResultTypeConfig.Builder("dispatcher", + ServletDispatcherResult.class.getName()).defaultResultParam("location").build(); + PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher", + new ResultTypeConfig[]{defaultResult}, null, null, null, true); + + final DummyContainer mockContainer = new DummyContainer(); + Configuration configuration = new DefaultConfiguration() { + @Override + public Container getContainer() { + return mockContainer; + } + }; + configuration.addPackageConfig("struts-default", strutsDefault); + + ActionNameBuilder actionNameBuilder = new SEOActionNameBuilder("true", "-"); + ObjectFactory of = new ObjectFactory(); + of.setContainer(mockContainer); + + mockContainer.setActionNameBuilder(actionNameBuilder); + mockContainer.setConventionsService(new ConventionsServiceImpl("")); + + PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder( + configuration, mockContainer, of, "false", "struts-default", "false"); + builder.setActionPackages("org.apache.struts2.convention.duplicate.unannotatedexecute"); + builder.setActionSuffix("Action"); + + DefaultFileManagerFactory fileManagerFactory = new DefaultFileManagerFactory(); + fileManagerFactory.setContainer(ActionContext.getContext().getContainer()); + fileManagerFactory.setFileManager(new DefaultFileManager()); + builder.setFileManagerFactory(fileManagerFactory); + builder.setProviderAllowlist(new ProviderAllowlist()); + + try { + builder.buildActionConfigs(); + fail("Expected ConfigurationException for duplicate action names"); + } catch (ConfigurationException e) { + assertTrue("Exception message should mention duplicate action names", + e.getMessage().contains("two methods with an action name annotation whose value is the same")); + } + } + public void testActionPackages() throws MalformedURLException { run("org.apache.struts2.convention.actions", null, null); } @@ -352,7 +450,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { expect(resultMapBuilder.build(GlobalResultAction.class, null, "global-result", globalResultPkg)).andReturn(results); expect(resultMapBuilder.build(GlobalResultOverrideAction.class, null, "global-result-override", globalResultPkg)).andReturn(results); expect(resultMapBuilder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "execute", Action.class), "action-level-results-names", resultPkg)).andReturn(results); - expect(resultMapBuilder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class), "action-level-results-names", resultPkg)).andReturn(results); + expect(resultMapBuilder.build(ActionLevelResultsNamesAction.class, getAnnotation(ActionLevelResultsNamesAction.class, "noname", Action.class), "action-level-results-names-noname", resultPkg)).andReturn(results); /* org.apache.struts2.convention.actions.resultpath */ expect(resultMapBuilder.build(ClassLevelResultPathAction.class, null, "class-level-result-path", resultPathPkg)).andReturn(results); @@ -662,11 +760,12 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { pkgConfig = configuration.getPackageConfig("org.apache.struts2.convention.actions.result#struts-default#/result"); assertNotNull(pkgConfig); checkSmiValue(pkgConfig, strutsDefault, isSmiInheritanceEnabled); - assertEquals(7, pkgConfig.getActionConfigs().size()); + assertEquals(8, pkgConfig.getActionConfigs().size()); verifyActionConfig(pkgConfig, "class-level-result", ClassLevelResultAction.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "class-level-results", ClassLevelResultsAction.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "action-level-result", ActionLevelResultAction.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "action-level-results", ActionLevelResultsAction.class, "execute", pkgConfig.getName()); + verifyActionConfig(pkgConfig, "action-level-results-names-noname", ActionLevelResultsNamesAction.class, "noname", pkgConfig.getName()); verifyActionConfig(pkgConfig, "inherited-result-extends", InheritedResultExtends.class, "execute", pkgConfig.getName()); /* org.apache.struts2.convention.actions.resultpath */ diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java b/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java index 2136f8d0a..8a8aee64d 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java @@ -28,16 +28,16 @@ import org.apache.struts2.convention.annotation.Result; */ public class ActionLevelResultsNamesAction { @Action(results = { - @Result(name={"error", "input"}, location="error.jsp"), - @Result(name="success", location="/WEB-INF/location/namespace/action-success.jsp"), - @Result(name="failure", location="/WEB-INF/location/namespace/action-failure.jsp") + @Result(name = {"error", "input"}, location = "error.jsp"), + @Result(name = "success", location = "/WEB-INF/location/namespace/action-success.jsp"), + @Result(name = "failure", location = "/WEB-INF/location/namespace/action-failure.jsp") }) public String execute() { return null; } - @Action(results = { - @Result(location="/WEB-INF/location/namespace/action-success.jsp") + @Action(value = "action-level-results-names-noname", results = { + @Result(location = "/WEB-INF/location/namespace/action-success.jsp") }) public String noname() { return null; diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/annotatedexecute/DuplicateActionNameWithExecuteAnnotationAction.java similarity index 57% copy from plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java copy to plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/annotatedexecute/DuplicateActionNameWithExecuteAnnotationAction.java index 2136f8d0a..89b3256d6 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/annotatedexecute/DuplicateActionNameWithExecuteAnnotationAction.java @@ -16,30 +16,23 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.struts2.convention.actions.result; +package org.apache.struts2.convention.duplicate.annotatedexecute; import org.apache.struts2.convention.annotation.Action; -import org.apache.struts2.convention.annotation.Result; /** - * <p> - * This is a test action with multiple results names. - * </p> + * Test action with duplicate @Action names where execute() is annotated. + * This is the case that was previously not detected (WW-4421). */ -public class ActionLevelResultsNamesAction { - @Action(results = { - @Result(name={"error", "input"}, location="error.jsp"), - @Result(name="success", location="/WEB-INF/location/namespace/action-success.jsp"), - @Result(name="failure", location="/WEB-INF/location/namespace/action-failure.jsp") - }) +public class DuplicateActionNameWithExecuteAnnotationAction { + + @Action("duplicate") public String execute() { - return null; + return "success"; } - @Action(results = { - @Result(location="/WEB-INF/location/namespace/action-success.jsp") - }) - public String noname() { - return null; + @Action("duplicate") + public String anotherMethod() { + return "success"; } } diff --git a/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/unannotatedexecute/DuplicateActionNameWithoutExecuteAnnotationAction.java similarity index 57% copy from plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java copy to plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/unannotatedexecute/DuplicateActionNameWithoutExecuteAnnotationAction.java index 2136f8d0a..8048fa2ff 100644 --- a/plugins/convention/src/test/java/org/apache/struts2/convention/actions/result/ActionLevelResultsNamesAction.java +++ b/plugins/convention/src/test/java/org/apache/struts2/convention/duplicate/unannotatedexecute/DuplicateActionNameWithoutExecuteAnnotationAction.java @@ -16,30 +16,27 @@ * specific language governing permissions and limitations * under the License. */ -package org.apache.struts2.convention.actions.result; +package org.apache.struts2.convention.duplicate.unannotatedexecute; import org.apache.struts2.convention.annotation.Action; -import org.apache.struts2.convention.annotation.Result; /** - * <p> - * This is a test action with multiple results names. - * </p> + * Test action with duplicate @Action names where execute() is NOT annotated. + * This is the case that was already detected before the fix (regression guard for WW-4421). */ -public class ActionLevelResultsNamesAction { - @Action(results = { - @Result(name={"error", "input"}, location="error.jsp"), - @Result(name="success", location="/WEB-INF/location/namespace/action-success.jsp"), - @Result(name="failure", location="/WEB-INF/location/namespace/action-failure.jsp") - }) +public class DuplicateActionNameWithoutExecuteAnnotationAction { + public String execute() { - return null; + return "success"; + } + + @Action("duplicate") + public String method1() { + return "success"; } - @Action(results = { - @Result(location="/WEB-INF/location/namespace/action-success.jsp") - }) - public String noname() { - return null; + @Action("duplicate") + public String method2() { + return "success"; } }
