diff --git a/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java b/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java index e856bfce8..6312b31a2 100644 --- a/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java +++ b/core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java @@ -80,4 +80,57 @@ public class WildcardHelperTest extends XWorkTestCase { assertEquals("", matchedPatterns.get("1")); } + /** + * WW-5594: Tests that pattern "org.apache.struts2.*" does NOT match "org.apache.struts2" + * (package name without trailing dot). + *

+ * This is important because when checking exclusion patterns, the convention plugin + * extracts package names from class names using StringUtils.substringBeforeLast(className, "."), + * which produces package names without trailing dots. + *

+ * For example: + * - Class "org.apache.struts2.XWorkTestCase" -> package "org.apache.struts2" (no trailing dot) + * - Pattern "org.apache.struts2.*" requires a literal "." before the wildcard + * - Result: Pattern doesn't match, class is not excluded + *

+ * The fix is to include both "org.apache.struts2" and "org.apache.struts2.*" in exclusion patterns. + */ + public void testWW5594_WildcardPatternRequiresTrailingDot() { + // given + HashMap matchedPatterns = new HashMap<>(); + int[] wildcardPattern = wildcardHelper.compilePattern("org.apache.struts2.*"); + + // when & then - Pattern with wildcard does NOT match package name without trailing dot + // This is the root cause of WW-5594 + assertFalse("Pattern 'org.apache.struts2.*' should NOT match 'org.apache.struts2' (no trailing dot)", + wildcardHelper.match(matchedPatterns, "org.apache.struts2", wildcardPattern)); + + // But it DOES match with trailing dot + assertTrue("Pattern 'org.apache.struts2.*' should match 'org.apache.struts2.' (with trailing dot)", + wildcardHelper.match(matchedPatterns, "org.apache.struts2.", wildcardPattern)); + + // And it DOES match full class names + assertTrue("Pattern 'org.apache.struts2.*' should match 'org.apache.struts2.SomeClass'", + wildcardHelper.match(matchedPatterns, "org.apache.struts2.SomeClass", wildcardPattern)); + } + + /** + * WW-5594: Tests that exact package pattern matches package names correctly. + * To properly exclude classes in a root package, use both the exact package name + * and the wildcard pattern. + */ + public void testWW5594_ExactPackagePatternMatchesPackageName() { + // given + HashMap matchedPatterns = new HashMap<>(); + int[] exactPattern = wildcardHelper.compilePattern("org.apache.struts2"); + + // when & then - Exact pattern matches exactly + assertTrue("Exact pattern 'org.apache.struts2' should match 'org.apache.struts2'", + wildcardHelper.match(matchedPatterns, "org.apache.struts2", exactPattern)); + + // But exact pattern does NOT match subpackages + assertFalse("Exact pattern 'org.apache.struts2' should NOT match 'org.apache.struts2.core'", + wildcardHelper.match(matchedPatterns, "org.apache.struts2.core", exactPattern)); + } + } 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 45eb3f8b8..ea9850568 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 @@ -18,6 +18,7 @@ */ package org.apache.struts2.convention; +import org.apache.commons.lang3.Strings; import org.apache.struts2.ActionContext; import org.apache.struts2.FileManager; import org.apache.struts2.FileManagerFactory; @@ -406,7 +407,7 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { if (ctx != null) { classLoaderInterface = (ClassLoaderInterface) ctx.get(ClassLoaderInterface.CLASS_LOADER_INTERFACE); } - return ObjectUtils.defaultIfNull(classLoaderInterface, new ClassLoaderInterfaceDelegate(getClassLoader())); + return ObjectUtils.getIfNull(classLoaderInterface, new ClassLoaderInterfaceDelegate(getClassLoader())); } } @@ -561,7 +562,17 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { } /** - * Checks if provided class package is on the exclude list + * Checks if provided class package is on the exclude list. + *

+ * WW-5594: For patterns ending with ".*", this method also checks if the package name + * equals the base pattern (without ".*"). This ensures that classes directly in the + * root package are excluded, not just classes in subpackages. + *

+ * For example, pattern "org.apache.struts2.*" will exclude both: + *

* * @param classPackageName name of class package * @return false if class package is on the {@link #excludePackages} list @@ -574,6 +585,16 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { Map matchMap = new HashMap<>(); for (String packageExclude : excludePackages) { + // WW-5594: For patterns ending with ".*", also check if package equals the base + // This handles root package exclusion (e.g., pattern "org.apache.struts2.*" + // should also exclude classes in package "org.apache.struts2") + if (packageExclude.endsWith(".*")) { + String basePackage = packageExclude.substring(0, packageExclude.length() - 2); + if (classPackageName.equals(basePackage)) { + return false; + } + } + int[] packagePattern = wildcardHelper.compilePattern(packageExclude); if (wildcardHelper.match(matchMap, classPackageName, packagePattern)) { return false; @@ -647,7 +668,7 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { * should be included in the package scan */ protected Test getActionClassTest() { - return new Test() { + return new Test<>() { public boolean test(ClassFinder.ClassInfo classInfo) { // Why do we call includeClassNameInActionScan here, when it's @@ -973,7 +994,7 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { String className = actionClass.getName(); if (annotation != null) { actionName = annotation.value().equals(Action.DEFAULT_VALUE) ? actionName : annotation.value(); - actionName = StringUtils.contains(actionName, "/") && !slashesInActionNames ? StringUtils.substringAfterLast(actionName, "/") : actionName; + actionName = Strings.CI.contains(actionName, "/") && !slashesInActionNames ? StringUtils.substringAfterLast(actionName, "/") : actionName; if (!Action.DEFAULT_VALUE.equals(annotation.className())) { className = annotation.className(); } @@ -1060,7 +1081,7 @@ public class PackageBasedActionConfigBuilder implements ActionConfigBuilder { if (action != null && !action.value().equals(Action.DEFAULT_VALUE)) { LOG.trace("Using non-default action namespace from the Action annotation of [{}]", action.value()); String actionName = action.value(); - actionNamespace = StringUtils.contains(actionName, "/") ? StringUtils.substringBeforeLast(actionName, "/") : StringUtils.EMPTY; + actionNamespace = Strings.CI.contains(actionName, "/") ? StringUtils.substringBeforeLast(actionName, "/") : StringUtils.EMPTY; } // Next grab the parent annotation from the class diff --git a/plugins/convention/src/main/resources/struts-plugin.xml b/plugins/convention/src/main/resources/struts-plugin.xml index 541c79f0c..afafe0f66 100644 --- a/plugins/convention/src/main/resources/struts-plugin.xml +++ b/plugins/convention/src/main/resources/struts-plugin.xml @@ -21,53 +21,61 @@ --> + "-//Apache Software Foundation//DTD Struts Configuration 6.0//EN" + "https://struts.apache.org/dtds/struts-6.0.dtd"> - + - - - - - + + + + + - - + + - - - - - + + + + + - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + - + - + - + - - + + 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 dcb0b4352..bb85dcac5 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 @@ -18,7 +18,6 @@ */ package org.apache.struts2.convention; -import org.apache.struts2.result.ActionChainResult; import jakarta.servlet.ServletContext; import junit.framework.TestCase; import org.apache.commons.lang3.StringUtils; @@ -94,6 +93,7 @@ import org.apache.struts2.inject.Container; import org.apache.struts2.inject.Scope.Strategy; import org.apache.struts2.ognl.OgnlReflectionProvider; import org.apache.struts2.ognl.ProviderAllowlist; +import org.apache.struts2.result.ActionChainResult; import org.apache.struts2.result.Result; import org.apache.struts2.result.ServletDispatcherResult; import org.apache.struts2.util.TextParseUtil; @@ -127,8 +127,8 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { public void setUp() throws Exception { super.setUp(); ActionContext.of() - .withContainer(new DummyContainer()) - .bind(); + .withContainer(new DummyContainer()) + .bind(); } public void testActionPackages() throws MalformedURLException { @@ -155,9 +155,83 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { run("org.apache.struts2.convention.actions", null, null, "false"); } + /** + * WW-5594: Tests that exclusion patterns properly exclude classes in root packages. + *

+ * The issue was that pattern "org.apache.struts2.*" did NOT match package name + * "org.apache.struts2" (without trailing dot) because: + * 1. PackageBasedActionConfigBuilder extracts package name using substringBeforeLast(className, ".") + * 2. For class "org.apache.struts2.XWorkTestCase", this produces "org.apache.struts2" (no trailing dot) + * 3. The wildcard pattern requires a literal "." before the "*" + *

+ * The fix is in checkExcludePackages() which now handles patterns ending with ".*" by also + * checking if the package name equals the base pattern (without ".*"). + */ + public void testWW5594_RootPackageExclusion() { + // Setup minimal configuration + final DummyContainer mockContainer = new DummyContainer(); + Configuration configuration = new DefaultConfiguration() { + @Override + public Container getContainer() { + return mockContainer; + } + }; + + ResultTypeConfig[] defaultResults = new ResultTypeConfig[]{ + new ResultTypeConfig.Builder("dispatcher", ServletDispatcherResult.class.getName()) + .defaultResultParam("location").build() + }; + PackageConfig strutsDefault = makePackageConfig("struts-default", null, null, "dispatcher", defaultResults); + 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"); + + // Test 1: Wildcard pattern now properly excludes root package classes (WW-5594 fix) + builder.setActionPackages("org.apache.struts2"); + builder.setExcludePackages("org.apache.struts2.*"); + + // Class in root package - package name is "org.apache.struts2" (no trailing dot) + // With the fix, pattern "org.apache.struts2.*" now also matches the base package + boolean includeRootPackageClass = builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase"); + assertFalse("With wildcard pattern, root package class should be excluded (WW-5594 fix)", + includeRootPackageClass); + + // Test 2: Subpackage classes should also be excluded by wildcard pattern + boolean includeSubpackageClass = builder.includeClassNameInActionScan("org.apache.struts2.core.ActionSupport"); + assertFalse("Subpackage classes should be excluded by wildcard pattern", + includeSubpackageClass); + + // Test 3: Exact pattern still works for specific package exclusion + builder.setExcludePackages("org.apache.struts2"); + boolean includeWithExactPattern = builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase"); + assertFalse("Exact pattern should exclude root package class", + includeWithExactPattern); + + // Test 4: Exact pattern should NOT exclude subpackage classes + boolean includeSubpackageWithExact = builder.includeClassNameInActionScan("org.apache.struts2.core.ActionSupport"); + assertTrue("Exact pattern should NOT exclude subpackage classes", + includeSubpackageWithExact); + + // Test 5: Classes in unrelated packages should NOT be excluded + builder.setActionPackages("com.example.actions"); + builder.setExcludePackages("org.apache.struts2.*"); + boolean includeUnrelated = builder.includeClassNameInActionScan("com.example.actions.MyAction"); + assertTrue("Classes in unrelated packages should not be excluded", + includeUnrelated); + } + private void run(String actionPackages, String packageLocators, String excludePackages) throws MalformedURLException { run(actionPackages, packageLocators, excludePackages, ""); } + private void run(String actionPackages, String packageLocators, String excludePackages, String enableSmiInheritance) throws MalformedURLException { //setup interceptors List defaultInterceptors = new ArrayList<>(); @@ -198,7 +272,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { PackageConfig classLevelParentPkg = makePackageConfig("class-level", null, null, null); PackageConfig rootPkg = makePackageConfig("org.apache.struts2.convention.actions#struts-default#", - "", strutsDefault, null); + "", strutsDefault, null); PackageConfig paramsPkg = makePackageConfig("org.apache.struts2.convention.actions.params#struts-default#/params", "/params", strutsDefault, null); PackageConfig defaultInterceptorPkg = makePackageConfig("org.apache.struts2.convention.actions.defaultinterceptor#struts-default#/defaultinterceptor", @@ -206,17 +280,17 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { PackageConfig exceptionPkg = makePackageConfig("org.apache.struts2.convention.actions.exception#struts-default#/exception", "/exception", strutsDefault, null); PackageConfig actionPkg = makePackageConfig("org.apache.struts2.convention.actions.action#struts-default#/action", - "/action", strutsDefault, null); + "/action", strutsDefault, null); PackageConfig idxPkg = makePackageConfig("org.apache.struts2.convention.actions.idx#struts-default#/idx", - "/idx", strutsDefault, null); + "/idx", strutsDefault, null); PackageConfig idx2Pkg = makePackageConfig("org.apache.struts2.convention.actions.idx.idx2#struts-default#/idx/idx2", - "/idx/idx2", strutsDefault, null); + "/idx/idx2", strutsDefault, null); PackageConfig interceptorRefsPkg = makePackageConfig("org.apache.struts2.convention.actions.interceptor#struts-default#/interceptor", "/interceptor", strutsDefault, null); PackageConfig packageLevelPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage#package-level#/parentpackage", - "/parentpackage", packageLevelParentPkg, null); + "/parentpackage", packageLevelParentPkg, null); PackageConfig packageLevelSubPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage.sub#package-level#/parentpackage/sub", - "/parentpackage/sub", packageLevelParentPkg, null); + "/parentpackage/sub", packageLevelParentPkg, null); // Unexpected method call build(class org.apache.struts2.convention.actions.allowedmethods.PackageLevelAllowedMethodsAction, null, "package-level-allowed-methods", PackageConfig: [org.apache.struts2.convention.actions.allowedmethods#struts-default#/allowedmethods] for namespace [/allowedmethods] with parents [[PackageConfig: [struts-default] for namespace [] with parents [[]]]]): PackageConfig packageLevelAllowedMethodsPkg = makePackageConfig("org.apache.struts2.convention.actions.allowedmethods#struts-default#/allowedmethods", @@ -228,17 +302,17 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { "/allowedmethods", strutsDefault, null); PackageConfig differentPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage#class-level#/parentpackage", - "/parentpackage", classLevelParentPkg, null); + "/parentpackage", classLevelParentPkg, null); PackageConfig differentSubPkg = makePackageConfig("org.apache.struts2.convention.actions.parentpackage.sub#class-level#/parentpackage/sub", - "/parentpackage/sub", classLevelParentPkg, null); + "/parentpackage/sub", classLevelParentPkg, null); PackageConfig pkgLevelNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/package-level", - "/package-level", strutsDefault, null); + "/package-level", strutsDefault, null); PackageConfig classLevelNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/class-level", - "/class-level", strutsDefault, null); + "/class-level", strutsDefault, null); PackageConfig actionLevelNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace#struts-default#/action-level", - "/action-level", strutsDefault, null); + "/action-level", strutsDefault, null); PackageConfig defaultNamespacePkg = makePackageConfig("org.apache.struts2.convention.actions.namespace2#struts-default#/namespace2", - "/namespace2", strutsDefault, null); + "/namespace2", strutsDefault, null); PackageConfig namespaces1Pkg = makePackageConfig("org.apache.struts2.convention.actions.namespace3#struts-default#/namespaces1", "/namespaces1", strutsDefault, null); PackageConfig namespaces2Pkg = makePackageConfig("org.apache.struts2.convention.actions.namespace3#struts-default#/namespaces2", @@ -248,19 +322,19 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { PackageConfig namespaces4Pkg = makePackageConfig("org.apache.struts2.convention.actions.namespace4#struts-default#/namespaces4", "/namespaces4", strutsDefault, null); PackageConfig resultPkg = makePackageConfig("org.apache.struts2.convention.actions.result#struts-default#/result", - "/result", strutsDefault, null); + "/result", strutsDefault, null); PackageConfig globalResultPkg = makePackageConfig("org.apache.struts2.convention.actions.result#class-level#/result", "/result", classLevelParentPkg, null); PackageConfig resultPathPkg = makePackageConfig("org.apache.struts2.convention.actions.resultpath#struts-default#/resultpath", - "/resultpath", strutsDefault, null); + "/resultpath", strutsDefault, null); PackageConfig skipPkg = makePackageConfig("org.apache.struts2.convention.actions.skip#struts-default#/skip", - "/skip", strutsDefault, null); + "/skip", strutsDefault, null); PackageConfig chainPkg = makePackageConfig("org.apache.struts2.convention.actions.chain#struts-default#/chain", - "/chain", strutsDefault, null); + "/chain", strutsDefault, null); PackageConfig transPkg = makePackageConfig("org.apache.struts2.convention.actions.transactions#struts-default#/transactions", - "/transactions", strutsDefault, null); + "/transactions", strutsDefault, null); PackageConfig excludePkg = makePackageConfig("org.apache.struts2.convention.actions.exclude#struts-default#/exclude", - "/exclude", strutsDefault, null); + "/exclude", strutsDefault, null); ResultMapBuilder resultMapBuilder = createStrictMock(ResultMapBuilder.class); checkOrder(resultMapBuilder, false); @@ -409,7 +483,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { mockContainer.setResultMapBuilder(resultMapBuilder); mockContainer.setConventionsService(new ConventionsServiceImpl("")); - PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(configuration, mockContainer , of, "false", "struts-default", enableSmiInheritance); + PackageBasedActionConfigBuilder builder = new PackageBasedActionConfigBuilder(configuration, mockContainer, of, "false", "struts-default", enableSmiInheritance); builder.setFileProtocols("jar"); if (actionPackages != null) { builder.setActionPackages(actionPackages); @@ -706,7 +780,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { verifyActionConfig(pkgConfig, "default-result-path", DefaultResultPathAction.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "skip", Skip.class, "execute", pkgConfig.getName()); verifyActionConfig(pkgConfig, "idx", org.apache.struts2.convention.actions.idx.Index.class, "execute", - "org.apache.struts2.convention.actions.idx#struts-default#/idx"); + "org.apache.struts2.convention.actions.idx#struts-default#/idx"); /* org.apache.struts2.convention.actions.transactions */ pkgConfig = configuration.getPackageConfig("org.apache.struts2.convention.actions.transactions#struts-default#/transactions"); @@ -741,7 +815,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } private void verifyActionConfig(PackageConfig pkgConfig, String actionName, Class actionClass, - String methodName, String packageName) { + String methodName, String packageName) { ActionConfig ac = pkgConfig.getAllActionConfigs().get(actionName); assertNotNull(ac); assertEquals(actionClass.getName(), ac.getClassName()); @@ -756,7 +830,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } private void verifyMissingActionConfig(PackageConfig pkgConfig, String actionName, Class actionClass, - String methodName, String packageName) { + String methodName, String packageName) { ActionConfig ac = pkgConfig.getAllActionConfigs().get(actionName); assertNull(ac); } @@ -769,10 +843,10 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { assertEquals(packageName, ac.getPackageName()); } - private void checkSmiValue(PackageConfig pkgConfig, PackageConfig parentConfig, boolean isSmiInheritanceEnabled) { + private void checkSmiValue(PackageConfig pkgConfig, PackageConfig parentConfig, boolean isSmiInheritanceEnabled) { if (isSmiInheritanceEnabled) { assertEquals(parentConfig.isStrictMethodInvocation(), pkgConfig.isStrictMethodInvocation()); - } else if (!isSmiInheritanceEnabled && !parentConfig.isStrictMethodInvocation()){ + } else if (!isSmiInheritanceEnabled && !parentConfig.isStrictMethodInvocation()) { assertTrue(pkgConfig.isStrictMethodInvocation()); } } @@ -788,13 +862,13 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } private PackageConfig makePackageConfig(String name, String namespace, PackageConfig parent, - String defaultResultType, ResultTypeConfig... results) { + String defaultResultType, ResultTypeConfig... results) { return makePackageConfig(name, namespace, parent, defaultResultType, results, null, null, null, true); } private PackageConfig makePackageConfig(String name, String namespace, PackageConfig parent, - String defaultResultType, ResultTypeConfig[] results, List interceptors, - List interceptorStacks, Set globalAllowedMethods, boolean strictMethodInvocation) { + String defaultResultType, ResultTypeConfig[] results, List interceptors, + List interceptorStacks, Set globalAllowedMethods, boolean strictMethodInvocation) { PackageConfig.Builder builder = new PackageConfig.Builder(name); if (namespace != null) { builder.namespace(namespace); @@ -852,7 +926,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { public boolean equals(Object obj) { PackageConfig other = (PackageConfig) obj; return getName().equals(other.getName()) && getNamespace().equals(other.getNamespace()) && - getParents().get(0) == other.getParents().get(0) && getParents().size() == other.getParents().size(); + getParents().get(0) == other.getParents().get(0) && getParents().size() == other.getParents().size(); } } @@ -893,7 +967,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { T obj; if (type == ObjectFactory.class) { obj = type.getConstructor().newInstance(); - ((ObjectFactory)obj).setContainer(this); + ((ObjectFactory) obj).setContainer(this); OgnlReflectionProvider rp = new OgnlReflectionProvider() { @@ -929,7 +1003,7 @@ public class PackageBasedActionConfigBuilderTest extends TestCase { } return obj; } catch (Exception e) { - throw new RuntimeException(e); + throw new RuntimeException(e); } } diff --git a/thoughts/shared/research/2025-12-02-WW-5594-wildcard-exclusion-pattern.md b/thoughts/shared/research/2025-12-02-WW-5594-wildcard-exclusion-pattern.md new file mode 100644 index 000000000..d0f05822a --- /dev/null +++ b/thoughts/shared/research/2025-12-02-WW-5594-wildcard-exclusion-pattern.md @@ -0,0 +1,460 @@ +--- +date: 2025-12-02T18:33:08+01:00 +topic: "WW-5594: Convention plugin exclusion pattern wildcard matching issue" +tags: [research, codebase, convention-plugin, wildcard-matching, configuration, bug-analysis, WW-5594] +status: complete +jira_ticket: WW-5594 +related_tickets: [WW-5593] +--- + +# Research: WW-5594 - Convention Plugin Wildcard Exclusion Pattern Issue + +**Date**: 2025-12-02T18:33:08+01:00 +**JIRA**: [WW-5594](https://issues.apache.org/jira/browse/WW-5594) +**Related**: [WW-5593](https://issues.apache.org/jira/browse/WW-5593) + +## Research Question + +Why doesn't the default exclusion pattern `org.apache.struts2.*` properly exclude classes directly in the `org.apache.struts2` package (like `XWorkTestCase`)? + +## Summary + +The convention plugin's `PackageBasedActionConfigBuilder` extracts package names from class names using `StringUtils.substringBeforeLast(className, ".")`, then matches these package names against exclusion patterns. For class `org.apache.struts2.XWorkTestCase`, this produces package name `org.apache.struts2` (no trailing dot). The pattern `org.apache.struts2.*` expects something after the dot for the `*` to match, causing a mismatch for classes directly in the root package. + +This is a conceptual mismatch between patterns designed for full class names vs. matching against extracted package names. + +## Detailed Findings + +### 1. Package Exclusion Logic + +**Location**: `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:558-584` + +#### Package Name Extraction (Line 558-560) + +```java +protected boolean includeClassNameInActionScan(String className) { + String classPackageName = StringUtils.substringBeforeLast(className, "."); + return (checkActionPackages(classPackageName) || checkPackageLocators(classPackageName)) && checkExcludePackages(classPackageName); +} +``` + +**Examples**: +- `"org.apache.struts2.core.ActionSupport"` → `"org.apache.struts2.core"` +- `"com.example.actions.UserAction"` → `"com.example.actions"` +- `"org.apache.struts2.XWorkTestCase"` → `"org.apache.struts2"` ⚠️ + +#### Exclusion Pattern Matching (Lines 569-584) + +```java +protected boolean checkExcludePackages(String classPackageName) { + if (excludePackages != null && excludePackages.length > 0) { + WildcardHelper wildcardHelper = new WildcardHelper(); + Map matchMap = new HashMap<>(); + + for (String packageExclude : excludePackages) { + int[] packagePattern = wildcardHelper.compilePattern(packageExclude); + if (wildcardHelper.match(matchMap, classPackageName, packagePattern)) { + return false; + } + } + } + return true; +} +``` + +### 2. The Conceptual Mismatch + +**The Problem**: + +When checking if `org.apache.struts2.XWorkTestCase` should be excluded: + +1. **Step 1**: Extract package name + - Input: `"org.apache.struts2.XWorkTestCase"` + - `substringBeforeLast(className, ".")` → `"org.apache.struts2"` + - No trailing dot + +2. **Step 2**: Match against pattern `org.apache.struts2.*` + - Pattern expects: `org.apache.struts2.` + `*` (something) + - Actual input: `org.apache.struts2` (nothing after last segment) + - **Mismatch**: Pattern requires a literal `.` that doesn't exist in the package name + +**Root Cause**: +- Exclusion patterns are written for **fully qualified class names** (e.g., `org.apache.struts2.*` should match `org.apache.struts2.XWorkTestCase`) +- But matching is performed against **package names only** (e.g., `org.apache.struts2` after stripping class name) +- This creates edge cases for classes directly in a package + +### 3. WildcardHelper Pattern Matching + +**Location**: `core/src/main/java/org/apache/struts2/util/WildcardHelper.java` + +#### Pattern Compilation Constants + +From `WildcardHelper.java:42-48`: +```java +public static final int MATCH_FILE = -1; // Single * - matches zero or more chars (excluding /) +public static final int MATCH_PATH = -2; // Double ** - matches zero or more chars (including /) +public static final int MATCH_BEGIN = -4; // Pattern must match from beginning +public static final int MATCH_THEEND = -5; // Pattern must match to the end +``` + +#### How Pattern `org.apache.struts2.*` Compiles + +The pattern compiles to: +``` +[MATCH_BEGIN, 'o', 'r', 'g', '.', 'a', 'p', 'a', 'c', 'h', 'e', '.', 's', 't', 'r', 'u', 't', 's', '2', '.', MATCH_FILE, MATCH_THEEND] +``` + +**Matching Requirements**: +1. Must start with `org.apache.struts2.` (literal characters) +2. Must have `MATCH_FILE` (zero or more non-slash characters) +3. Must end exactly (`MATCH_THEEND`) + +#### Test Evidence + +From `core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java:54-76`: + +```java +public void testMatchStrutsPackages() { + // given + HashMap matchedPatterns = new HashMap<>(); + int[] pattern = wildcardHelper.compilePattern("org.apache.struts2.*"); + + // when & then + assertTrue(wildcardHelper.match(matchedPatterns, "org.apache.struts2.XWorkTestCase", pattern)); + assertEquals("org.apache.struts2.XWorkTestCase", matchedPatterns.get("0")); + assertEquals("XWorkTestCase", matchedPatterns.get("1")); + + assertTrue(wildcardHelper.match(matchedPatterns, "org.apache.struts2.core.SomeClass", pattern)); + assertEquals("org.apache.struts2.core.SomeClass", matchedPatterns.get("0")); + assertEquals("core.SomeClass", matchedPatterns.get("1")); + + // IMPORTANT: Pattern matches even with nothing after dot + assertTrue(wildcardHelper.match(matchedPatterns, "org.apache.struts2.", pattern)); + assertEquals("org.apache.struts2.", matchedPatterns.get("0")); + assertEquals("", matchedPatterns.get("1")); +} +``` + +**Key Insight**: The pattern `org.apache.struts2.*` **WILL match** `org.apache.struts2.` (with trailing dot) because `MATCH_FILE` can match zero characters. However, it **WON'T match** `org.apache.struts2` (without trailing dot) because the literal `.` character before `MATCH_FILE` is required. + +### 4. Why This Matters + +**Scenario**: Class `org.apache.struts2.XWorkTestCase` should be excluded + +**What Happens**: +1. Pattern in config: `org.apache.struts2.*` +2. Class name: `org.apache.struts2.XWorkTestCase` +3. Extracted package: `org.apache.struts2` (no trailing dot) +4. Pattern match: `"org.apache.struts2"` vs pattern `"org.apache.struts2.*"` +5. **Result**: NO MATCH (pattern expects literal `.` before the `*`) +6. Class is NOT excluded +7. Convention plugin tries to scan it +8. Triggers WW-5593 (NoClassDefFoundError) + +**Contrast with subpackage classes**: +1. Class name: `org.apache.struts2.core.ActionSupport` +2. Extracted package: `org.apache.struts2.core` +3. Pattern match: `"org.apache.struts2.core"` vs pattern `"org.apache.struts2.*"` +4. **Result**: NO MATCH (pattern expects `org.apache.struts2.` + something, but we have `org.apache.struts2.core` which is a different string) + +**Wait, that's also wrong!** Let me reconsider... + +Actually, looking at the test more carefully: + +The test shows that `"org.apache.struts2.*"` matches: +- `"org.apache.struts2.XWorkTestCase"` (full class name) +- `"org.apache.struts2.core.SomeClass"` (subpackage class name) + +But in the actual code, we're matching: +- `"org.apache.struts2"` (extracted package, no class name) + +So the pattern `"org.apache.struts2.*"` is designed to match **class names** like `"org.apache.struts2.XWorkTestCase"`, but it's being used to match **package names** like `"org.apache.struts2"`. + +### 5. Default Configuration + +**Location**: `plugins/convention/src/main/resources/struts-plugin.xml:57` + +```xml + +``` + +**Packages That Should Be Excluded**: +- `org.apache.struts.*` - Struts 1.x packages +- `org.apache.struts2.*` - Struts 2.x packages +- `org.springframework.web.struts.*` - Spring Struts integration +- `org.springframework.web.struts2.*` - Spring Struts 2 integration +- `org.hibernate.*` - Hibernate packages + +**Problem**: Classes directly in these root packages (without subpackages) may not be properly excluded: +- `org.apache.struts2.XWorkTestCase` ❌ Not excluded +- `org.apache.struts2.StrutsConstants` ❌ Not excluded +- But: `org.apache.struts2.core.ActionSupport` - needs investigation + +### 6. Email Thread Evidence + +From Florian Schlittgen's debugging (2024-12-19 21:41): + +```java +public static void main(String[] args) { + String packageExclude = "org.apache.struts2.*"; + String classPackageName = "org.apache.struts2"; + WildcardHelper wildcardHelper = new WildcardHelper(); + int[] packagePattern = wildcardHelper.compilePattern(packageExclude); + System.out.println(wildcardHelper.match(new HashMap<>(), classPackageName, packagePattern)); +} +``` + +**Output**: `false` + +The pattern `"org.apache.struts2.*"` does NOT match `"org.apache.struts2"` (without trailing dot). + +From Lukasz Lenart's response (2024-12-23 19:12): + +```java +// This WORKS (with trailing dot) +String classPackageName = "org.apache.struts2."; +// Output: true +``` + +So the pattern requires a trailing dot to match. + +## Code References + +- `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java:558-584` - Package exclusion logic +- `core/src/main/java/org/apache/struts2/util/WildcardHelper.java:71-254` - Wildcard pattern matching implementation +- `core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java:54-76` - Pattern matching tests +- `plugins/convention/src/main/resources/struts-plugin.xml:57` - Default exclusion configuration + +## Recommended Fixes + +### Option A: Update Default Configuration (Simplest) + +**File**: `plugins/convention/src/main/resources/struts-plugin.xml:57` + +Add both root packages and wildcard patterns to the configuration. + +**Disadvantages**: +- ❌ Users with custom exclusion patterns still need to know about this +- ❌ Makes the configuration string longer +- ❌ Requires pattern duplication + +### Option B: Match Against Full Class Name + +Match exclusion patterns against the full class name instead of just the package name. + +**Disadvantages**: +- ❌ Changes method logic significantly +- ❌ May affect performance (two wildcard checks instead of one) +- ❌ Could have unintended side effects with existing configurations + +### Option C: Enhance WildcardHelper for Package Matching + +Create a new method `matchPackagePattern()` that understands package-style matching. + +**Disadvantages**: +- ❌ Most complex solution +- ❌ Changes core utility class +- ❌ Requires extensive testing +- ❌ Overkill for the problem + +### Option D: Enhance checkExcludePackages to Handle Root Packages (IMPLEMENTED ✅) + +**File**: `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java` + +Modify `checkExcludePackages()` to automatically handle patterns ending with `.*` by also checking if the package name equals the base pattern (without `.*`). + +**Implementation**: +```java +protected boolean checkExcludePackages(String classPackageName) { + if (excludePackages != null && excludePackages.length > 0) { + WildcardHelper wildcardHelper = new WildcardHelper(); + Map matchMap = new HashMap<>(); + + for (String packageExclude : excludePackages) { + // WW-5594: For patterns ending with ".*", also check if package equals the base + if (packageExclude.endsWith(".*")) { + String basePackage = packageExclude.substring(0, packageExclude.length() - 2); + if (classPackageName.equals(basePackage)) { + return false; + } + } + + int[] packagePattern = wildcardHelper.compilePattern(packageExclude); + if (wildcardHelper.match(matchMap, classPackageName, packagePattern)) { + return false; + } + } + } + return true; +} +``` + +**Advantages**: +- ✅ No configuration duplication needed +- ✅ Backward compatible - existing configurations with `.*` patterns now work correctly +- ✅ Single point of fix in `checkExcludePackages()` method +- ✅ Intuitive behavior - `org.apache.struts2.*` now excludes both root and subpackages +- ✅ Minimal code change +- ✅ Users don't need to update their configurations + +**Disadvantages**: +- ❌ Slightly more processing per pattern (trivial performance impact) + +### Recommendation + +**Option D** was chosen and implemented because: +1. Most elegant solution - no configuration duplication +2. Backward compatible - existing configurations work better +3. Single point of fix +4. Intuitive behavior for users +5. Minimal code change with maximum benefit + +## Impact Assessment + +### Current Risk + +**Severity**: MEDIUM + +**Scenarios Affected**: +1. Classes directly in root Struts packages (like `XWorkTestCase`) +2. Users setting `struts.convention.action.includeJars` +3. Custom exclusion patterns not including root packages + +**Current Behavior**: +- ❌ Test classes in root packages not excluded +- ❌ Can trigger WW-5593 (NoClassDefFoundError) +- ❌ Users must manually add root packages to exclusions +- ❌ Non-intuitive pattern matching behavior + +### After Fix + +**Expected Behavior**: +- ✅ Root package classes properly excluded by default +- ✅ Reduces occurrence of WW-5593 +- ✅ More intuitive default configuration +- ✅ Better documentation for custom patterns + +## Testing Strategy + +### Unit Tests to Add + +1. **Test Root Package Exclusion**: +```java +@Test +public void testExcludeRootPackageClasses() { + String[] excludePackages = {"org.apache.struts2", "org.apache.struts2.*"}; + builder.setExcludePackages(excludePackages); + + // Should exclude classes directly in org.apache.struts2 + assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase")); + assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.StrutsConstants")); + + // Should also exclude subpackages + assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.core.ActionSupport")); + assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.dispatcher.Dispatcher")); +} +``` + +2. **Test Wildcard Pattern Behavior**: +```java +@Test +public void testWildcardPatternMatchingWithPackageNames() { + WildcardHelper helper = new WildcardHelper(); + int[] pattern = helper.compilePattern("org.apache.struts2.*"); + + // Package name without trailing dot should NOT match + assertFalse(helper.match(new HashMap<>(), "org.apache.struts2", pattern)); + + // Package name with trailing dot SHOULD match + assertTrue(helper.match(new HashMap<>(), "org.apache.struts2.", pattern)); + + // Full class name SHOULD match + assertTrue(helper.match(new HashMap<>(), "org.apache.struts2.XWorkTestCase", pattern)); +} +``` + +3. **Test Default Configuration**: +```java +@Test +public void testDefaultExclusionPatterns() { + // Load default patterns from struts-plugin.xml + builder.setExcludePackages(getDefaultExclusionPatterns()); + + // Should exclude Struts 2 root package classes + assertFalse(builder.includeClassNameInActionScan("org.apache.struts2.XWorkTestCase")); + + // Should exclude Struts 1 root package classes + assertFalse(builder.includeClassNameInActionScan("org.apache.struts.Action")); + + // Should exclude Hibernate root package classes + assertFalse(builder.includeClassNameInActionScan("org.hibernate.Session")); +} +``` + +### Integration Tests + +1. Test scanning with `struts.convention.action.includeJars` configured +2. Verify classes in root excluded packages are not scanned +3. Verify classes in subpackages are properly excluded +4. Verify user action classes are still included + +## Workaround for Users + +Until the fix is released, users should update their exclusion patterns: + +```xml + +``` + +**Pattern**: For each package to exclude, include both: +- Root package without wildcard: `org.apache.struts2` +- Subpackages with wildcard: `org.apache.struts2.*` + +## Documentation Updates Needed + +1. **Convention Plugin Documentation**: + - Explain wildcard pattern matching behavior + - Provide examples of correct exclusion patterns + - Document the difference between `org.example` and `org.example.*` + +2. **Migration Guide**: + - Note the improved default exclusions in release notes + - Explain that custom exclusion patterns should include root packages + +3. **Configuration Reference**: + - Update `struts.convention.exclude.packages` documentation + - Add examples showing both patterns + +## Related Issues + +- **WW-5593**: NoClassDefFoundError bug that this exclusion issue can trigger +- Related research: `thoughts/shared/research/2025-12-02-WW-5593-convention-plugin-noclass-exception.md` + +## Email Thread Reference + +**From**: Florian Schlittgen (sc...@liwa.de) +**Date**: December 19, 2024 - January 19, 2025 +**Subject**: Struts 7: action class finder +**List**: dev@struts.apache.org + +**Key Insight from Thread** (Florian, 2024-12-23 19:41): +> "Thanks for looking into it. Your examples/tests are alright, but I think this is not the way it is being called. Please take a look at org.apache.struts2.convention.PackageBasedActionConfigBuilder.includeClassNameInActionScan(String). While debugging I can see that this method is being called with parameter "className" = "org.apache.struts2.XWorkTestCase". In the method's first line the package name is derived from the className by using "StringUtils.substringBeforeLast(className, ".")" which gives you "org.apache.struts2", no trailing dot. And without trailing dot it is not working." + +## Implementation Status + +1. ✅ Create JIRA ticket WW-5594 +2. ✅ Modify `checkExcludePackages()` to handle `.*` patterns for root packages (Option D) +3. ✅ Default exclusion patterns in `struts-plugin.xml` unchanged (no duplication needed) +4. ✅ Add unit tests for root package exclusion (`PackageBasedActionConfigBuilderTest.testWW5594_RootPackageExclusion`) +5. ✅ Add unit tests documenting WildcardHelper behavior (`WildcardHelperTest.testWW5594_*`) +6. ⏳ Add integration tests with includeJars configured +7. ⏳ Update documentation for pattern matching behavior +8. ⏳ Update migration guide with new default patterns + +## Files Changed + +- `plugins/convention/src/main/java/org/apache/struts2/convention/PackageBasedActionConfigBuilder.java` - Enhanced `checkExcludePackages()` method +- `plugins/convention/src/test/java/org/apache/struts2/convention/PackageBasedActionConfigBuilderTest.java` - Added `testWW5594_RootPackageExclusion()` +- `core/src/test/java/org/apache/struts2/util/WildcardHelperTest.java` - Added `testWW5594_WildcardPatternRequiresTrailingDot()` and `testWW5594_ExactPackagePatternMatchesPackageName()` \ No newline at end of file