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 0cb27d0786b62071e6ade713e88d24e5ed3a2fee
Author: Lukasz Lenart <[email protected]>
AuthorDate: Mon Nov 2 09:50:04 2026 +0100

    WW-4164 fix(core): parse probe names like the mapper and keep allowed 
extensionless links
    
    - Resolver cuts a target name at the last ! only when DMI is enabled, so a
      literal report!daily resolves its own action
    - Resolver treats an extensionless current request as "" for declared
      targets, keeping it when allowed
    - Mapper declaration probe refuses names cleanupActionName replaced with the
      default action name
    - Mapper declaration probe derives the name through parseActionName, 
matching
      the final mapping for DMI on and off
    
    Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 .../dispatcher/mapper/ActionExtensionResolver.java | 51 +++++++++++++++-------
 .../dispatcher/mapper/DefaultActionMapper.java     | 14 ++++--
 .../mapper/ActionExtensionResolverTest.java        | 37 ++++++++++++++++
 .../dispatcher/mapper/DefaultActionMapperTest.java | 37 ++++++++++++++++
 .../config/providers/struts-extension-bang.xml     | 38 ++++++++++++++++
 5 files changed, 157 insertions(+), 20 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 6849bdb56..f322b10f0 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
@@ -18,6 +18,7 @@
  */
 package org.apache.struts2.dispatcher.mapper;
 
+import org.apache.commons.lang3.BooleanUtils;
 import org.apache.commons.lang3.StringUtils;
 import org.apache.struts2.ActionContext;
 import org.apache.struts2.StrutsConstants;
@@ -38,6 +39,7 @@ public class ActionExtensionResolver {
 
     private Configuration configuration;
     private List<String> extensions;
+    private boolean allowDynamicMethodCalls;
     private volatile DeclarationState declarationState;
 
     /**
@@ -58,13 +60,24 @@ public class ActionExtensionResolver {
         this.extensions = StringUtils.isNotEmpty(extensions) ? 
ActionExtensions.parse(extensions) : null;
     }
 
+    /**
+     * @param enableDynamicMethodCalls whether {@code name!method} names are 
split at their last {@code !}
+     * @since 7.5.0
+     */
+    @Inject(StrutsConstants.STRUTS_ENABLE_DYNAMIC_METHOD_INVOCATION)
+    public void setAllowDynamicMethodCalls(String enableDynamicMethodCalls) {
+        this.allowDynamicMethodCalls = 
BooleanUtils.toBoolean(enableDynamicMethodCalls);
+    }
+
     /**
      * Sets the extension of {@code target} to the current request's extension 
when the target accepts it, otherwise
-     * to the target's first accepted extension, which is also used when the 
current request has no extension, as in
-     * {@link DefaultActionMapper#lookupExtension(String)}. Leaves {@code 
target} unchanged when it already has an extension,
-     * when no action in the configuration declares extensions, when the 
target action is unknown (a name reached only
-     * through the namespace's {@code default-action-ref} counts as unknown), 
or when neither the
-     * target nor the global constant declares extensions.
+     * to the target's first accepted extension. For a target declaring 
extensions an extensionless request counts as
+     * the empty extension; the first accepted extension is used when there is 
no current request, or for an
+     * extensionless request to an undeclared target, as in {@link 
DefaultActionMapper#lookupExtension(String)}.
+     * Leaves {@code target} unchanged when it already has an extension, when 
no action in the configuration declares
+     * extensions, when the target action is unknown (a name reached only 
through the namespace's
+     * {@code default-action-ref} counts as unknown), or when neither the 
target nor the global constant declares
+     * extensions.
      *
      * @param target the mapping of the action the URL points at
      * @since 7.5.0
@@ -77,17 +90,26 @@ public class ActionExtensionResolver {
         if (!anyActionDeclaresExtensions(runtimeConfiguration)) {
             return;
         }
-        String actionName = 
StringUtils.substringBefore(StringUtils.substringBefore(target.getName(), "?"), 
"!");
-        ActionConfig actionConfig = 
runtimeConfiguration.getMatchedActionConfig(target.getNamespace(), actionName);
+        ActionConfig actionConfig = 
runtimeConfiguration.getMatchedActionConfig(target.getNamespace(), 
actionName(target.getName()));
         if (actionConfig == null) {
             return;
         }
-        List<String> effective = !actionConfig.getExtensions().isEmpty() ? 
actionConfig.getExtensions() : extensions;
-        if (effective == null) {
+        ActionMapping currentMapping = currentMapping();
+        List<String> declared = actionConfig.getExtensions();
+        if (!declared.isEmpty()) {
+            target.setExtension(currentMapping == null ? declared.get(0) : 
ActionExtensions.select(declared, currentMapping.getExtension()));
             return;
         }
-        String current = currentExtension();
-        target.setExtension(current == null ? effective.get(0) : 
ActionExtensions.select(effective, current));
+        if (extensions == null) {
+            return;
+        }
+        String current = currentMapping == null ? null : 
currentMapping.getExtension();
+        target.setExtension(current == null ? extensions.get(0) : 
ActionExtensions.select(extensions, current));
+    }
+
+    private String actionName(String name) {
+        String withoutQuery = StringUtils.substringBefore(name, "?");
+        return allowDynamicMethodCalls ? 
StringUtils.substringBeforeLast(withoutQuery, "!") : withoutQuery;
     }
 
     private boolean anyActionDeclaresExtensions(RuntimeConfiguration 
runtimeConfiguration) {
@@ -102,12 +124,9 @@ public class ActionExtensionResolver {
         return anyDeclared;
     }
 
-    private String currentExtension() {
+    private ActionMapping currentMapping() {
         ActionContext context = ActionContext.getContext();
-        if (context == null || context.getActionMapping() == null) {
-            return null;
-        }
-        return context.getActionMapping().getExtension();
+        return context == null ? null : context.getActionMapping();
     }
 
     private static final class DeclarationState {
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 bb624688b..63208956f 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
@@ -511,13 +511,19 @@ public class DefaultActionMapper implements ActionMapper {
     private boolean declaresExtensions(String candidate, RuntimeConfiguration 
runtimeConfiguration, ConfigurationManager configManager) {
         ActionMapping probe = new ActionMapping();
         parseNameAndNamespace(candidate, probe, configManager);
-        String name = probe.getName();
-        int bang = name.indexOf('!');
-        String actionName = bang == -1 ? name : name.substring(0, bang);
-        ActionConfig config = 
runtimeConfiguration.getMatchedActionConfig(probe.getNamespace(), actionName);
+        if (!keepsRequestedName(candidate, probe.getName())) {
+            return false;
+        }
+        parseActionName(probe);
+        ActionConfig config = 
runtimeConfiguration.getMatchedActionConfig(probe.getNamespace(), 
probe.getName());
         return config != null && !config.getExtensions().isEmpty();
     }
 
+    private static boolean keepsRequestedName(String uri, String name) {
+        String lastSegment = uri.substring(uri.lastIndexOf('/') + 1);
+        return name.equals(lastSegment) || name.endsWith("/" + lastSegment);
+    }
+
     private DeclaredExtensions declaredExtensions(RuntimeConfiguration 
runtimeConfiguration) {
         DeclaredExtensions cached = declaredExtensions;
         if (cached != null && cached.runtimeConfiguration == 
runtimeConfiguration) {
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 0713e9a88..a1e98405c 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
@@ -80,6 +80,7 @@ public class ActionExtensionResolverTest extends 
StrutsInternalTestCase {
     }
 
     public void testDmiNameResolvesActionConfig() {
+        resolver.setAllowDynamicMethodCalls("true");
         withCurrentExtension("action");
 
         assertThat(resolve("/child", "inherits!foo")).isEqualTo("html");
@@ -125,6 +126,42 @@ public class ActionExtensionResolverTest extends 
StrutsInternalTestCase {
         assertThat(resolve("", "Bar")).isNull();
     }
 
+    public void testLiteralBangNameWithoutDmi() {
+        useBangConfig();
+        withCurrentExtension("action");
+
+        assertThat(resolve("/bang", "report!daily")).isEqualTo("html");
+    }
+
+    public void testBangNameWithDmiResolvesActionBeforeLastBang() {
+        useBangConfig();
+        resolver.setAllowDynamicMethodCalls("true");
+        withCurrentExtension("action");
+
+        assertThat(resolve("/bang", "report!daily")).isEqualTo("json");
+    }
+
+    public void testExtensionlessPageKeepsAllowedEmptyExtension() {
+        withCurrentExtension(null);
+
+        assertThat(resolve("/child", "own")).isEmpty();
+    }
+
+    public void testExtensionlessPageUsesFirstWhenEmptyNotAllowed() {
+        withCurrentExtension(null);
+
+        assertThat(resolve("/child", "inherits")).isEqualTo("html");
+    }
+
+    public void testWithoutCurrentMappingDeclaredTargetUsesFirst() {
+        assertThat(resolve("/child", "own")).isEqualTo("json");
+    }
+
+    private void useBangConfig() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider("org/apache/struts2/config/providers/struts-extension-bang.xml"));
+        resolver.setConfiguration(configuration);
+    }
+
     private String resolve(String namespace, String name) {
         ActionMapping target = new ActionMapping(name, namespace, null, null);
         resolver.applyTo(target);
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 96810e955..7113f60d8 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
@@ -49,6 +49,7 @@ public class DefaultActionMapperTest extends 
StrutsInternalTestCase {
     private static final String EXTENSION_CONFIG = 
"org/apache/struts2/config/providers/struts-extension.xml";
     private static final String EXTENSION_RELOAD_CONFIG = 
"org/apache/struts2/config/providers/struts-extension-reload.xml";
     private static final String EXTENSION_DEFAULT_ACTION_CONFIG = 
"org/apache/struts2/config/providers/struts-extension-default-action.xml";
+    private static final String EXTENSION_BANG_CONFIG = 
"org/apache/struts2/config/providers/struts-extension-bang.xml";
 
     private MockHttpServletRequest req;
     private ConfigurationManager configManager;
@@ -1146,6 +1147,42 @@ public class DefaultActionMapperTest extends 
StrutsInternalTestCase {
         assertThat(mapDeclared(mapper, 
"/plain/undeclared.action").getName()).isEqualTo("undeclared");
     }
 
+    public void testDeclaringDefaultActionDoesNotClaimDisallowedName() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_DEFAULT_ACTION_CONFIG));
+
+        assertThat(mapDeclared(declaredMapper(), "/manual(1).html")).isNull();
+    }
+
+    public void testDeclaredExtensionClaimedForLiteralBangNameWithoutDmi() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_BANG_CONFIG));
+
+        ActionMapping mapping = mapDeclared(declaredMapper(), 
"/bang-only/report!daily.html");
+
+        assertThat(mapping.getName()).isEqualTo("report!daily");
+        assertThat(mapping.getMethod()).isNull();
+        assertThat(mapping.getExtension()).isEqualTo("html");
+    }
+
+    public void testDeclaredExtensionClaimedForDmiName() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_BANG_CONFIG));
+        DefaultActionMapper mapper = declaredMapper();
+        mapper.setAllowDynamicMethodCalls("true");
+
+        ActionMapping mapping = mapDeclared(mapper, "/dmi/report!daily.html");
+
+        assertThat(mapping.getName()).isEqualTo("report");
+        assertThat(mapping.getMethod()).isEqualTo("daily");
+        assertThat(mapping.getExtension()).isEqualTo("html");
+    }
+
+    public void testDeclaredExtensionProbeSplitsDmiNameAtLastBang() {
+        loadConfigurationProviders(new 
StrutsXmlConfigurationProvider(EXTENSION_BANG_CONFIG));
+        DefaultActionMapper mapper = declaredMapper();
+        mapper.setAllowDynamicMethodCalls("true");
+
+        assertThat(mapDeclared(mapper, "/dmi/report!x!daily.html")).isNull();
+    }
+
     private DefaultActionMapper declaredMapper() {
         DefaultActionMapper mapper = new DefaultActionMapper();
         mapper.setExtensions("action,");
diff --git 
a/core/src/test/resources/org/apache/struts2/config/providers/struts-extension-bang.xml
 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-extension-bang.xml
new file mode 100644
index 000000000..bd2f7a011
--- /dev/null
+++ 
b/core/src/test/resources/org/apache/struts2/config/providers/struts-extension-bang.xml
@@ -0,0 +1,38 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *  http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+-->
+<!DOCTYPE struts PUBLIC
+    "-//Apache Software Foundation//DTD Struts Configuration 7.5//EN"
+    "https://struts.apache.org/dtds/struts-7.5.dtd";>
+<struts>
+    <package name="bang" namespace="/bang">
+        <action name="report!daily" class="org.apache.struts2.TestAction" 
extension="html"/>
+        <action name="report" class="org.apache.struts2.TestAction" 
extension="json"/>
+    </package>
+
+    <package name="bang-only" namespace="/bang-only">
+        <action name="report!daily" class="org.apache.struts2.TestAction" 
extension="html"/>
+    </package>
+
+    <package name="dmi" namespace="/dmi">
+        <action name="report" class="org.apache.struts2.TestAction" 
extension="html"/>
+    </package>
+</struts>

Reply via email to