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,");

Reply via email to