This is an automated email from the ASF dual-hosted git repository. lukaszlenart pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/struts.git
commit 06cd4669bcbd63b3ef9bde1b67eff04f55217952 Author: Lukasz Lenart <[email protected]> AuthorDate: Mon Nov 2 08:33:28 2026 +0100 WW-4164 fix(core): resolve query-string action names and declared empty extension The outbound resolver now cuts the target name at '?' as well as '!', so action="name?x=1" targets pick up the declared extension. When only package or action attributes declare the empty extension, an extensionless URI that matches a declaring action is mapped instead of being dropped because the global struts.action.extension lacks "". Co-Authored-By: Claude Opus 5.5 <[email protected]> --- .../dispatcher/mapper/ActionExtensionResolver.java | 4 +-- .../dispatcher/mapper/DefaultActionMapper.java | 32 +++++++++++++++------- .../mapper/ActionExtensionResolverTest.java | 6 ++++ .../dispatcher/mapper/DefaultActionMapperTest.java | 21 ++++++++++++++ 4 files changed, 51 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolver.java b/core/src/main/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolver.java index aad463bb6..a223d1d65 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolver.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolver.java @@ -77,8 +77,8 @@ public class ActionExtensionResolver { if (!anyActionDeclaresExtensions(runtimeConfiguration)) { return; } - ActionConfig actionConfig = runtimeConfiguration - .getMatchedActionConfig(target.getNamespace(), StringUtils.substringBefore(target.getName(), "!")); + String actionName = StringUtils.substringBefore(StringUtils.substringBefore(target.getName(), "?"), "!"); + ActionConfig actionConfig = runtimeConfiguration.getMatchedActionConfig(target.getNamespace(), actionName); if (actionConfig == null) { return; } diff --git a/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java b/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java index 0c3640408..9fc17241f 100644 --- a/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java +++ b/core/src/main/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapper.java @@ -477,7 +477,8 @@ public class DefaultActionMapper implements ActionMapper { /** * Strips an extension that only package or action {@code extension} attributes declare, but only when the * remaining URI matches an action (exactly or by wildcard, never through a {@code default-action-ref}) that itself - * declares extensions. + * declares extensions. When only those attributes declare the empty extension, an extensionless URI matching such + * an action is returned as is. * * @param uri the request URI * @param mapping the action mapping to store the extension in @@ -490,7 +491,8 @@ public class DefaultActionMapper implements ActionMapper { if (runtimeConfiguration == null) { return null; } - for (String ext : declaredOnlyExtensions(runtimeConfiguration)) { + DeclaredExtensions declared = declaredExtensions(runtimeConfiguration); + for (String ext : declared.declaredOnly) { String suffix = "." + ext; if (uri.endsWith(suffix)) { String candidate = uri.substring(0, uri.length() - suffix.length()); @@ -500,6 +502,9 @@ public class DefaultActionMapper implements ActionMapper { } } } + if (declared.emptyDeclaredOnly && hasNoExtension(uri) && declaresExtensions(uri, runtimeConfiguration, configManager)) { + return uri; + } return null; } @@ -513,10 +518,10 @@ public class DefaultActionMapper implements ActionMapper { return config != null && config.getExtensions() != null; } - private List<String> declaredOnlyExtensions(RuntimeConfiguration runtimeConfiguration) { + private DeclaredExtensions declaredExtensions(RuntimeConfiguration runtimeConfiguration) { DeclaredExtensions cached = declaredExtensions; if (cached != null && cached.runtimeConfiguration == runtimeConfiguration) { - return cached.declaredOnly; + return cached; } Set<String> declared = new HashSet<>(); for (Map<String, ActionConfig> actionConfigs : runtimeConfiguration.getActionConfigs().values()) { @@ -526,27 +531,35 @@ public class DefaultActionMapper implements ActionMapper { } } } - declared.remove(""); + boolean emptyDeclaredOnly = declared.remove("") && extensions != null && !extensions.contains(""); if (extensions != null) { extensions.forEach(declared::remove); } List<String> declaredOnly = declared.stream() .sorted(Comparator.comparingInt(String::length).reversed().thenComparing(Comparator.naturalOrder())) .toList(); - declaredExtensions = new DeclaredExtensions(runtimeConfiguration, declaredOnly); - return declaredOnly; + DeclaredExtensions computed = new DeclaredExtensions(runtimeConfiguration, declaredOnly, emptyDeclaredOnly); + declaredExtensions = computed; + return computed; } private static final class DeclaredExtensions { private final RuntimeConfiguration runtimeConfiguration; private final List<String> declaredOnly; + private final boolean emptyDeclaredOnly; - private DeclaredExtensions(RuntimeConfiguration runtimeConfiguration, List<String> declaredOnly) { + private DeclaredExtensions(RuntimeConfiguration runtimeConfiguration, List<String> declaredOnly, boolean emptyDeclaredOnly) { this.runtimeConfiguration = runtimeConfiguration; this.declaredOnly = declaredOnly; + this.emptyDeclaredOnly = emptyDeclaredOnly; } } + private static boolean hasNoExtension(String name) { + int index = name.lastIndexOf('.'); + return index == -1 || name.indexOf('/', index) >= 0; + } + /** * Drops the extension from the action name, storing it in the mapping for later use * @@ -563,8 +576,7 @@ public class DefaultActionMapper implements ActionMapper { // This should also handle cases such as /foo/bar-1.0/description. It is tricky to // distinquish /foo/bar-1.0 but perhaps adding a numeric check in the future could // work - int index = name.lastIndexOf('.'); - if (index == -1 || name.indexOf('/', index) >= 0) { + if (hasNoExtension(name)) { return name; } } else { diff --git a/core/src/test/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolverTest.java b/core/src/test/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolverTest.java index 1e45a55a3..0713e9a88 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolverTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/mapper/ActionExtensionResolverTest.java @@ -85,6 +85,12 @@ public class ActionExtensionResolverTest extends StrutsInternalTestCase { assertThat(resolve("/child", "inherits!foo")).isEqualTo("html"); } + public void testQueryStringNameResolvesActionConfig() { + withCurrentExtension("action"); + + assertThat(resolve("/child", "inherits?x=1")).isEqualTo("html"); + } + public void testUnknownNameResolvedOnlyThroughDeclaringDefaultActionUntouched() { loadConfigurationProviders(new StrutsXmlConfigurationProvider("org/apache/struts2/config/providers/struts-extension-default-action.xml")); resolver.setConfiguration(configuration); diff --git a/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java b/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java index e5ffd6e4a..96810e955 100644 --- a/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java +++ b/core/src/test/java/org/apache/struts2/dispatcher/mapper/DefaultActionMapperTest.java @@ -1125,6 +1125,27 @@ public class DefaultActionMapperTest extends StrutsInternalTestCase { assertThat(mapping.getExtension()).isEqualTo("html"); } + public void testDeclaredEmptyExtensionReachableWhenGlobalListLacksIt() { + loadConfigurationProviders(new StrutsXmlConfigurationProvider(EXTENSION_CONFIG)); + DefaultActionMapper mapper = new DefaultActionMapper(); + mapper.setExtensions("action"); + + ActionMapping mapping = mapDeclared(mapper, "/plain/none"); + + assertThat(mapping.getName()).isEqualTo("none"); + assertThat(mapping.getNamespace()).isEqualTo("/plain"); + assertThat(mapping.getExtension()).isNull(); + } + + public void testUndeclaredActionStaysUnreachableWithoutExtensionWhenGlobalListLacksEmpty() { + loadConfigurationProviders(new StrutsXmlConfigurationProvider(EXTENSION_CONFIG)); + DefaultActionMapper mapper = new DefaultActionMapper(); + mapper.setExtensions("action"); + + assertThat(mapDeclared(mapper, "/plain/undeclared")).isNull(); + assertThat(mapDeclared(mapper, "/plain/undeclared.action").getName()).isEqualTo("undeclared"); + } + private DefaultActionMapper declaredMapper() { DefaultActionMapper mapper = new DefaultActionMapper(); mapper.setExtensions("action,");
