This is an automated email from the ASF dual-hosted git repository.

lukaszlenart pushed a commit to branch support/struts-6-x-x
in repository https://gitbox.apache.org/repos/asf/struts.git


The following commit(s) were added to refs/heads/support/struts-6-x-x by this 
push:
     new 7ce27107e WW-5668 Make the localized-text provider caches size-bounded 
and align request-locale resolution (6.x) (#1823)
7ce27107e is described below

commit 7ce27107e2d40ac7619205e6de9ae7106d3ee455
Author: Lukasz Lenart <[email protected]>
AuthorDate: Sat Aug 1 11:55:00 2026 +0200

    WW-5668 Make the localized-text provider caches size-bounded and align 
request-locale resolution (6.x) (#1823)
    
    * WW-5668 Add remove(key) to the OgnlCache abstraction
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    
    * WW-5668 Bound the localized-text provider caches with configurable size
    
    Converts bundlesMap, messageFormats and missingBundles to the existing
    OgnlCache abstraction, configurable via struts.i18n.cacheType and
    struts.i18n.cacheMaxSize (wtlfu / 10000 by default). The caches are kept
    transient and rebuilt in readObject so the providers stay serializable,
    and bundlesMap-related synchronization moves to a dedicated monitor since
    the field is now reassignable.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    
    * WW-5668 Add opt-in request-locale resolution consistency to Dispatcher
    
    Adds struts.locale.validateRequestLocale (default false) so request-derived
    locales can be restricted to the JVM's available-locale set, matching what
    I18nInterceptor already applies to its own locale sources.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    
    * WW-5668 Keep the localized-text providers deserializable across a version 
upgrade
    
    Pins serialVersionUID to the value implicitly computed for the pre-6.11.0 
class
    shape instead of 1L, so a session serialized by a 6.10.0 node still loads 
on a
    6.11.0 one during a rolling upgrade rather than failing with 
InvalidClassException.
    
    Such a stream carries no value for the new cache settings, and field 
initialisers
    do not run during deserialization, so readObject restores their defaults 
before
    rebuilding the caches - without that guard it failed with a 
NullPointerException.
    
    Co-Authored-By: Claude Opus 5 <[email protected]>
    
    ---------
    
    Co-authored-by: Claude Opus 5 <[email protected]>
---
 .../com/opensymphony/xwork2/ognl/OgnlCache.java    |   9 ++
 .../xwork2/ognl/OgnlCaffeineCache.java             |   5 +
 .../opensymphony/xwork2/ognl/OgnlDefaultCache.java |   5 +
 .../com/opensymphony/xwork2/ognl/OgnlLRUCache.java |   5 +
 .../xwork2/util/AbstractLocalizedTextProvider.java | 115 ++++++++++++++++++---
 .../xwork2/util/GlobalLocalizedTextProvider.java   |   3 +
 .../xwork2/util/StrutsLocalizedTextProvider.java   |   3 +
 .../java/org/apache/struts2/StrutsConstants.java   |  24 +++++
 .../org/apache/struts2/dispatcher/Dispatcher.java  |  48 ++++++++-
 .../org/apache/struts2/default.properties          |  10 ++
 .../xwork2/ognl/OgnlCacheRemoveTest.java           |  50 +++++++++
 .../com/opensymphony/xwork2/util/CacheFixture.java |  28 +++++
 .../util/StrutsLocalizedTextProviderTest.java      | 108 +++++++++++++++++++
 .../apache/struts2/dispatcher/DispatcherTest.java  |  40 +++++++
 .../xwork2/util/CacheFixture.properties            |  19 ++++
 15 files changed, 457 insertions(+), 15 deletions(-)

diff --git a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java 
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
index fc8366699..908e18be7 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCache.java
@@ -31,6 +31,15 @@ public interface OgnlCache<Key, Value> {
 
     void putIfAbsent(Key key, Value value);
 
+    /**
+     * Removes the mapping for the given key, if present.
+     *
+     * @param key the key to remove
+     * @return the previous value associated with the key, or {@code null} if 
none
+     * @since 6.11.0
+     */
+    Value remove(Key key);
+
     int size();
 
     void clear();
diff --git 
a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java 
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
index b7a0241e3..9e2040176 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlCaffeineCache.java
@@ -56,6 +56,11 @@ public class OgnlCaffeineCache<K, V> implements OgnlCache<K, 
V> {
         cache.asMap().putIfAbsent(key, value);
     }
 
+    @Override
+    public V remove(K key) {
+        return cache.asMap().remove(key);
+    }
+
     @Override
     public int size() {
         return cache.asMap().size();
diff --git 
a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java 
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
index 920403a5d..c631b8cb0 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlDefaultCache.java
@@ -57,6 +57,11 @@ public class OgnlDefaultCache<K, V> implements OgnlCache<K, 
V> {
         this.clearIfEvictionLimitExceeded();
     }
 
+    @Override
+    public V remove(K key) {
+        return ognlCache.remove(key);
+    }
+
     @Override
     public int size() {
         return ognlCache.size();
diff --git a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java 
b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
index e324418ce..cbc5461f3 100644
--- a/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
+++ b/core/src/main/java/com/opensymphony/xwork2/ognl/OgnlLRUCache.java
@@ -64,6 +64,11 @@ public class OgnlLRUCache<K, V> implements OgnlCache<K, V> {
         ognlLRUCache.putIfAbsent(key, value);
     }
 
+    @Override
+    public V remove(K key) {
+        return ognlLRUCache.remove(key);
+    }
+
     @Override
     public int size() {
         return ognlLRUCache.size();
diff --git 
a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
 
b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
index 2d6a7c678..6b972d923 100644
--- 
a/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
+++ 
b/core/src/main/java/com/opensymphony/xwork2/util/AbstractLocalizedTextProvider.java
@@ -21,6 +21,10 @@ package com.opensymphony.xwork2.util;
 import com.opensymphony.xwork2.ActionContext;
 import com.opensymphony.xwork2.LocalizedTextProvider;
 import com.opensymphony.xwork2.inject.Inject;
+import com.opensymphony.xwork2.ognl.DefaultOgnlCacheFactory;
+import com.opensymphony.xwork2.ognl.OgnlCache;
+import com.opensymphony.xwork2.ognl.OgnlCacheFactory.CacheType;
+import org.apache.commons.lang3.EnumUtils;
 import org.apache.commons.lang3.ObjectUtils;
 import org.apache.logging.log4j.LogManager;
 import org.apache.logging.log4j.Logger;
@@ -45,6 +49,11 @@ import java.util.concurrent.CopyOnWriteArrayList;
 
 abstract class AbstractLocalizedTextProvider implements LocalizedTextProvider {
 
+    // Pinned to the value implicitly computed for the pre-6.11.0 class shape, 
so sessions serialized by
+    // an older node still deserialize here during a rolling upgrade. The 
caches this change made transient
+    // are simply discarded from such a stream and rebuilt by readObject.
+    private static final long serialVersionUID = -4563130226985473584L;
+
     private static final Logger LOG = 
LogManager.getLogger(AbstractLocalizedTextProvider.class);
 
     public static final String XWORK_MESSAGES_BUNDLE = 
"com/opensymphony/xwork2/xwork-messages";
@@ -56,16 +65,37 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
     private static final String TOMCAT_WEBAPP_CLASSLOADER_BASE = 
"org.apache.catalina.loader.WebappClassLoaderBase";
     private static final String RELOADED = 
"com.opensymphony.xwork2.util.LocalizedTextProvider.reloaded";
 
-    protected final ConcurrentMap<String, ResourceBundle> bundlesMap = new 
ConcurrentHashMap<>();
     protected boolean devMode = false;
     protected boolean reloadBundles = false;
     protected boolean searchDefaultBundlesFirst = false;  // Search default 
resource bundles first.  Note: This flag may not be meaningful to all 
implementations.
 
-    private final ConcurrentMap<MessageFormatKey, MessageFormat> 
messageFormats = new ConcurrentHashMap<>();
     private final ConcurrentMap<Integer, List<String>> classLoaderMap = new 
ConcurrentHashMap<>();
-    private final Set<String> missingBundles = ConcurrentHashMap.newKeySet();
     private final ConcurrentMap<Integer, ClassLoader> delegatedClassLoaderMap 
= new ConcurrentHashMap<>();
 
+    // Dedicated monitor for bundlesMap-related synchronization: bundlesMap is 
reassigned by
+    // rebuildI18nCaches(), so locking on it directly would lock on a monitor 
that can change identity.
+    // transient + reinitialised in readObject: a bare Object is not 
Serializable.
+    private transient Object bundlesMapLock = new Object();
+
+    private static final int DEFAULT_I18N_CACHE_MAX_SIZE = 10000;
+
+    private volatile CacheType i18nCacheType = CacheType.WTLFU;
+    private volatile int i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;
+
+    private <K, V> OgnlCache<K, V> buildI18nCache() {
+        return new DefaultOgnlCacheFactory<K, V>(i18nCacheMaxSize, 
i18nCacheType).buildOgnlCache();
+    }
+
+    // The OgnlCache implementations are themselves thread-safe; volatile only 
safely publishes the
+    // reference when rebuildI18nCaches() replaces a cache (during injection / 
readObject), so S3077
+    // ("volatile is not enough") does not apply here.
+    @SuppressWarnings("java:S3077")
+    protected transient volatile OgnlCache<String, ResourceBundle> bundlesMap 
= buildI18nCache();
+    @SuppressWarnings("java:S3077")
+    private transient volatile OgnlCache<MessageFormatKey, MessageFormat> 
messageFormats = buildI18nCache();
+    @SuppressWarnings("java:S3077")
+    private transient volatile OgnlCache<String, Boolean> missingBundles = 
buildI18nCache();
+
     /**
      * Adds the bundle to the internal list of default bundles.
      * If the bundle already exists in the list it will be re-added.
@@ -99,6 +129,21 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         return Thread.currentThread().getContextClassLoader();
     }
 
+    /** Test-support accessor: current number of cached resource bundles. */
+    protected int bundlesMapSize() {
+        return bundlesMap.size();
+    }
+
+    /** Test-support accessor: current number of cached missing-bundle 
markers. */
+    protected int missingBundlesSize() {
+        return missingBundles.size();
+    }
+
+    /** Test-support accessor: current number of cached message formats. */
+    protected int messageFormatsSize() {
+        return messageFormats.size();
+    }
+
     @Inject(value = StrutsConstants.STRUTS_CUSTOM_I18N_RESOURCES, required = 
false)
     public void setCustomI18NResources(String bundles) {
         if (bundles != null && bundles.length() > 0) {
@@ -221,7 +266,7 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
      * @param classLoader a {@link ClassLoader} to look up the bundle from if 
none can be found on the current thread's classloader
      */
     public void setDelegatedClassLoader(final ClassLoader classLoader) {
-        synchronized (bundlesMap) {
+        synchronized (bundlesMapLock) {
             
delegatedClassLoaderMap.put(getCurrentThreadContextClassLoader().hashCode(), 
classLoader);
         }
     }
@@ -443,6 +488,52 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         this.searchDefaultBundlesFirst = 
Boolean.parseBoolean(searchDefaultBundlesFirst);
     }
 
+    /**
+     * @param cacheType the type of cache to use for the localized-text caches
+     *
+     * @since 6.11.0
+     */
+    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_TYPE, required = false)
+    public void setI18nCacheType(String cacheType) {
+        this.i18nCacheType = EnumUtils.getEnumIgnoreCase(CacheType.class, 
cacheType, CacheType.WTLFU);
+        rebuildI18nCaches();
+    }
+
+    /**
+     * @param cacheMaxSize the maximum size of each localized-text cache
+     *
+     * @since 6.11.0
+     */
+    @Inject(value = StrutsConstants.STRUTS_I18N_CACHE_MAXSIZE, required = 
false)
+    public void setI18nCacheMaxSize(String cacheMaxSize) {
+        this.i18nCacheMaxSize = Integer.parseInt(cacheMaxSize);
+        rebuildI18nCaches();
+    }
+
+    /**
+     * Rebuilds the localized-text caches from the current type/size. Called 
during dependency injection
+     * (single-threaded startup, before the provider serves lookups); discards 
any warm-up entries.
+     */
+    private void rebuildI18nCaches() {
+        bundlesMap = buildI18nCache();
+        messageFormats = buildI18nCache();
+        missingBundles = buildI18nCache();
+    }
+
+    private void readObject(java.io.ObjectInputStream in) throws 
java.io.IOException, ClassNotFoundException {
+        in.defaultReadObject();
+        bundlesMapLock = new Object();
+        // Field initialisers do not run during deserialization, so a stream 
written before these settings
+        // existed (an older node in a rolling upgrade) leaves them at null/0. 
Restore the defaults.
+        if (i18nCacheType == null) {
+            i18nCacheType = CacheType.WTLFU;
+        }
+        if (i18nCacheMaxSize <= 0) {
+            i18nCacheMaxSize = DEFAULT_I18N_CACHE_MAX_SIZE;
+        }
+        rebuildI18nCaches();
+    }
+
     /**
      * Finds the given resource bundle by it's name.
      * <p>
@@ -458,34 +549,32 @@ abstract class AbstractLocalizedTextProvider implements 
LocalizedTextProvider {
         ClassLoader classLoader = getCurrentThreadContextClassLoader();
         String key = createMissesKey(String.valueOf(classLoader.hashCode()), 
aBundleName, locale);
 
-        if (missingBundles.contains(key)) {
+        if (missingBundles.get(key) != null) {
             return null;
         }
 
         ResourceBundle bundle = null;
         try {
-            if (bundlesMap.containsKey(key)) {
-                bundle = bundlesMap.get(key);
-            } else {
+            bundle = bundlesMap.get(key);
+            if (bundle == null) {
                 bundle = ResourceBundle.getBundle(aBundleName, locale, 
classLoader);
                 bundlesMap.putIfAbsent(key, bundle);
             }
         } catch (MissingResourceException ex) {
             if (delegatedClassLoaderMap.containsKey(classLoader.hashCode())) {
                 try {
-                    if (bundlesMap.containsKey(key)) {
-                        bundle = bundlesMap.get(key);
-                    } else {
+                    bundle = bundlesMap.get(key);
+                    if (bundle == null) {
                         bundle = ResourceBundle.getBundle(aBundleName, locale, 
delegatedClassLoaderMap.get(classLoader.hashCode()));
                         bundlesMap.putIfAbsent(key, bundle);
                     }
                 } catch (MissingResourceException e) {
                     LOG.debug("Missing resource bundle [{}]!", aBundleName, e);
-                    missingBundles.add(key);
+                    missingBundles.put(key, Boolean.TRUE);
                 }
             } else {
                 LOG.debug("Missing resource bundle [{}]!", aBundleName);
-                missingBundles.add(key);
+                missingBundles.put(key, Boolean.TRUE);
             }
         }
         return bundle;
diff --git 
a/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
 
b/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
index d86682fd1..e568dfbc1 100644
--- 
a/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
+++ 
b/core/src/main/java/com/opensymphony/xwork2/util/GlobalLocalizedTextProvider.java
@@ -33,6 +33,9 @@ import java.util.ResourceBundle;
  */
 public class GlobalLocalizedTextProvider extends AbstractLocalizedTextProvider 
{
 
+    // Pinned to the value implicitly computed for the pre-6.11.0 class shape, 
see AbstractLocalizedTextProvider.
+    private static final long serialVersionUID = 7569216885652454296L;
+
     private static final Logger LOG = 
LogManager.getLogger(GlobalLocalizedTextProvider.class);
 
     public GlobalLocalizedTextProvider() {
diff --git 
a/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
 
b/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
index 60ef3477a..963654ac3 100644
--- 
a/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
+++ 
b/core/src/main/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProvider.java
@@ -36,6 +36,9 @@ import java.util.ResourceBundle;
  */
 public class StrutsLocalizedTextProvider extends AbstractLocalizedTextProvider 
{
 
+    // Pinned to the value implicitly computed for the pre-6.11.0 class shape, 
see AbstractLocalizedTextProvider.
+    private static final long serialVersionUID = -4377984952850818176L;
+
     private static final Logger LOG = 
LogManager.getLogger(StrutsLocalizedTextProvider.class);
 
     /**
diff --git a/core/src/main/java/org/apache/struts2/StrutsConstants.java 
b/core/src/main/java/org/apache/struts2/StrutsConstants.java
index 7138aa3c7..31b82d059 100644
--- a/core/src/main/java/org/apache/struts2/StrutsConstants.java
+++ b/core/src/main/java/org/apache/struts2/StrutsConstants.java
@@ -100,6 +100,14 @@ public final class StrutsConstants {
     /** The default locale for the Struts application */
     public static final String STRUTS_LOCALE = "struts.locale";
 
+    /**
+     * When enabled, request-derived locales (from {@code Accept-Language}, 
used when {@code struts.locale} is
+     * unset) are restricted to the JVM's available-locale set; unavailable 
values fall back to the default.
+     *
+     * @since 6.11.0
+     */
+    public static final String STRUTS_LOCALE_VALIDATE_REQUEST = 
"struts.locale.validateRequestLocale";
+
     /** Whether to use a Servlet request parameter workaround necessary for 
some versions of WebLogic */
     public static final String STRUTS_DISPATCHER_PARAMETERSWORKAROUND = 
"struts.dispatcher.parametersWorkaround";
 
@@ -288,6 +296,22 @@ public final class StrutsConstants {
      */
     public static final String STRUTS_OGNL_BEANINFO_CACHE_FACTORY = 
"struts.ognl.beanInfoCacheFactory";
 
+    /**
+     * Specifies the type of cache to use for the localized-text provider 
caches. Valid values defined in
+     * {@link com.opensymphony.xwork2.ognl.OgnlCacheFactory.CacheType}.
+     *
+     * @since 6.11.0
+     */
+    public static final String STRUTS_I18N_CACHE_TYPE = 
"struts.i18n.cacheType";
+
+    /**
+     * Specifies the maximum size of each localized-text provider cache. 
Configure based on the cache type
+     * chosen and application-specific needs.
+     *
+     * @since 6.11.0
+     */
+    public static final String STRUTS_I18N_CACHE_MAXSIZE = 
"struts.i18n.cacheMaxSize";
+
     /**
      * Specifies the type of cache to use for BeanInfo objects.
      * @since 6.4.0
diff --git a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java 
b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
index 55f0228ba..4b774dedf 100644
--- a/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
+++ b/core/src/main/java/org/apache/struts2/dispatcher/Dispatcher.java
@@ -152,6 +152,11 @@ public class Dispatcher {
      */
     private String defaultLocale;
 
+    /**
+     * Store state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST} 
setting.
+     */
+    private boolean validateRequestLocale = false;
+
     /**
      * Store state of StrutsConstants.STRUTS_MULTIPART_SAVEDIR setting.
      */
@@ -311,6 +316,18 @@ public class Dispatcher {
         defaultLocale = val;
     }
 
+    /**
+     * Modify state of {@link StrutsConstants#STRUTS_LOCALE_VALIDATE_REQUEST} 
setting.
+     *
+     * @param val New setting
+     *
+     * @since 6.11.0
+     */
+    @Inject(value = StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, required = 
false)
+    public void setValidateRequestLocale(String val) {
+        validateRequestLocale = Boolean.parseBoolean(val);
+    }
+
     /**
      * Modify state of StrutsConstants.STRUTS_I18N_ENCODING setting.
      *
@@ -950,7 +967,7 @@ public class Dispatcher {
                 locale = LocaleUtils.toLocale(defaultLocale);
             } catch (IllegalArgumentException e) {
                 try {
-                    locale = request.getLocale();
+                    locale = resolveRequestLocale(request);
                     LOG.warn(new ParameterizedMessage("Cannot convert 
'struts.locale' = [{}] to proper locale, defaulting to request locale [{}]",
                                     defaultLocale, locale), e);
                 } catch (RuntimeException rex) {
@@ -961,7 +978,7 @@ public class Dispatcher {
             }
         } else {
             try {
-                locale = request.getLocale();
+                locale = resolveRequestLocale(request);
             } catch (RuntimeException rex) {
                 LOG.warn("Cannot get locale from HTTP Request, falling back to 
system default locale", rex);
                 locale = Locale.getDefault();
@@ -970,6 +987,33 @@ public class Dispatcher {
         return locale;
     }
 
+    /**
+     * Resolves the request locale. When {@code 
struts.locale.validateRequestLocale} is enabled and the
+     * request locale is not part of the JVM's available-locale set, falls 
back to the configured
+     * {@code struts.locale} when set and parseable, otherwise the JVM 
default. When disabled (default),
+     * returns the request locale unchanged.
+     *
+     * @param request the current request
+     * @return the locale to use for this request
+     *
+     * @since 6.11.0
+     */
+    protected Locale resolveRequestLocale(HttpServletRequest request) {
+        Locale locale = request.getLocale();
+        if (!validateRequestLocale || LocaleUtils.isAvailableLocale(locale)) {
+            return locale;
+        }
+        if (defaultLocale != null) {
+            try {
+                return LocaleUtils.toLocale(defaultLocale);
+            } catch (IllegalArgumentException e) {
+                LOG.debug("Configured 'struts.locale' = [{}] is not parseable; 
falling back to system default", defaultLocale);
+            }
+        }
+        LOG.debug("Request locale [{}] is not available; falling back to 
system default locale", locale);
+        return Locale.getDefault();
+    }
+
     /**
      * Return the path to save uploaded files to (this is configurable).
      *
diff --git a/core/src/main/resources/org/apache/struts2/default.properties 
b/core/src/main/resources/org/apache/struts2/default.properties
index 2ab573b87..bdf85cdff 100644
--- a/core/src/main/resources/org/apache/struts2/default.properties
+++ b/core/src/main/resources/org/apache/struts2/default.properties
@@ -24,6 +24,9 @@
 
 ### This can be used to set your default locale and encoding scheme
 # struts.locale=en_US
+### When true, restrict request-derived locales (Accept-Language, used when 
struts.locale is unset) to the
+### JVM's available-locale set; unavailable values fall back to the default 
locale. Defaults to false.
+struts.locale.validateRequestLocale=false
 struts.i18n.encoding=UTF-8
 
 ### if specified, the default object factory can be overridden here
@@ -240,6 +243,13 @@ struts.ognl.expressionCacheType=wtlfu
 ### chosen and application-specific needs.
 struts.ognl.expressionCacheMaxSize=10000
 
+### Specifies the type of cache to use for the localized-text provider caches. 
See StrutsConstants for details.
+struts.i18n.cacheType=wtlfu
+
+### Specifies the maximum size of each localized-text provider cache. This 
should be configured based on the
+### cache type chosen and application-specific needs.
+struts.i18n.cacheMaxSize=10000
+
 ### Specifies the type of cache to use for BeanInfo objects. See 
StrutsConstants class for further information.
 struts.ognl.beanInfoCacheType=wtlfu
 
diff --git 
a/core/src/test/java/com/opensymphony/xwork2/ognl/OgnlCacheRemoveTest.java 
b/core/src/test/java/com/opensymphony/xwork2/ognl/OgnlCacheRemoveTest.java
new file mode 100644
index 000000000..c637bc41f
--- /dev/null
+++ b/core/src/test/java/com/opensymphony/xwork2/ognl/OgnlCacheRemoveTest.java
@@ -0,0 +1,50 @@
+/*
+ * 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.
+ */
+package com.opensymphony.xwork2.ognl;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+
+public class OgnlCacheRemoveTest {
+
+    private void assertRemoveContract(OgnlCache<String, String> cache) {
+        cache.put("k", "v");
+        assertEquals("v", cache.get("k"));
+        assertEquals("remove returns previous value", "v", cache.remove("k"));
+        assertNull("entry gone after remove", cache.get("k"));
+        assertNull("remove of absent key returns null", 
cache.remove("absent"));
+    }
+
+    @Test
+    public void caffeineCacheRemove() {
+        assertRemoveContract(new OgnlCaffeineCache<>(10, 16));
+    }
+
+    @Test
+    public void defaultCacheRemove() {
+        assertRemoveContract(new OgnlDefaultCache<>(10, 16, 0.75f));
+    }
+
+    @Test
+    public void lruCacheRemove() {
+        assertRemoveContract(new OgnlLRUCache<>(10, 16, 0.75f));
+    }
+}
diff --git a/core/src/test/java/com/opensymphony/xwork2/util/CacheFixture.java 
b/core/src/test/java/com/opensymphony/xwork2/util/CacheFixture.java
new file mode 100644
index 000000000..027aec163
--- /dev/null
+++ b/core/src/test/java/com/opensymphony/xwork2/util/CacheFixture.java
@@ -0,0 +1,28 @@
+/*
+ * 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.
+ */
+package com.opensymphony.xwork2.util;
+
+/**
+ * Simple fixture whose class-associated bundle ({@code 
CacheFixture.properties}) backs the
+ * localized-text caching tests.
+ *
+ * @since 6.11.0
+ */
+public class CacheFixture {
+}
diff --git 
a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
 
b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
index 7bb8af612..dea14bec8 100644
--- 
a/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
+++ 
b/core/src/test/java/com/opensymphony/xwork2/util/StrutsLocalizedTextProviderTest.java
@@ -34,6 +34,11 @@ import com.opensymphony.xwork2.test.TestBean2;
 import org.apache.struts2.config.StrutsXmlConfigurationProvider;
 import org.apache.struts2.interceptor.parameter.StrutsParameter;
 
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.ObjectInputStream;
+import java.io.ObjectOutputStream;
+import java.lang.reflect.Field;
 import java.text.DateFormat;
 import java.text.ParseException;
 import java.util.Date;
@@ -563,6 +568,109 @@ public class StrutsLocalizedTextProviderTest extends 
XWorkTestCase {
         assertEquals("Result of bean2.name lookup not as expected ?", "Okay! 
You found Me!", messageResult);
     }
 
+    public void testCachesAreBoundedByConfiguredMaxSize() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheMaxSize("100");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        for (int i = 0; i < 20000; i++) {
+            Locale locale = Locale.forLanguageTag("en-US-x" + 
String.format("%05d", i));
+            provider.findText(CacheFixture.class, "cache.missing", locale, 
"Fallback", null, valueStack);
+        }
+
+        assertTrue("bundlesMap not bounded ?", provider.bundlesMapSize() <= 
2000);
+        assertTrue("missingBundles not bounded ?", 
provider.missingBundlesSize() <= 2000);
+        assertTrue("messageFormats not bounded ?", 
provider.messageFormatsSize() <= 2000);
+    }
+
+    public void testCorrectTextStillReturnedUnderEviction() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheMaxSize("50");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        // Force heavy eviction with many distinct locales.
+        for (int i = 0; i < 5000; i++) {
+            Locale locale = Locale.forLanguageTag("en-US-x" + 
String.format("%05d", i));
+            provider.findText(CacheFixture.class, "cache.missing", locale, 
"Fallback", null, valueStack);
+        }
+
+        // A real key in a real locale still resolves correctly after eviction 
pressure.
+        String result = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+    }
+
+    public void testReloadClearsBoundedCaches() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+
+        provider.findText(CacheFixture.class, "cache.missing", Locale.ENGLISH, 
"Fallback", null, valueStack);
+        assertTrue("missingBundles not populated ?", 
provider.missingBundlesSize() > 0);
+
+        provider.callReloadBundlesForceReload();
+        assertEquals("reload did not clear bundlesMap ?", 0, 
provider.bundlesMapSize());
+    }
+
+    public void testProviderIsUsableAfterDeserialization() throws Exception {
+        StrutsLocalizedTextProvider provider = new 
StrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+            oos.writeObject(provider);
+        }
+        Object restored;
+        try (ObjectInputStream ois = new ObjectInputStream(new 
ByteArrayInputStream(baos.toByteArray()))) {
+            restored = ois.readObject();
+        }
+        StrutsLocalizedTextProvider deserialized = 
(StrutsLocalizedTextProvider) restored;
+        // Caches were transient (null right after defaultReadObject) but 
readObject rebuilds them:
+        assertEquals("Deserialized caches not rebuilt empty", 0, 
deserialized.bundlesMapSize());
+        String result = deserialized.findText(CacheFixture.class, 
"cache.static", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+    }
+
+    /**
+     * A stream written before the i18n cache settings existed carries no 
value for them, and field
+     * initialisers do not run during deserialization, so they arrive as 
null/0. The provider must still
+     * come back usable rather than failing while rebuilding its caches.
+     */
+    public void testProviderIsUsableAfterDeserializingLegacyStream() throws 
Exception {
+        StrutsLocalizedTextProvider provider = new 
StrutsLocalizedTextProvider();
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        provider.findText(CacheFixture.class, "cache.static", Locale.ENGLISH, 
null, null, valueStack);
+
+        // Simulate the absent-field state an older stream produces.
+        Field cacheType = 
AbstractLocalizedTextProvider.class.getDeclaredField("i18nCacheType");
+        cacheType.setAccessible(true);
+        cacheType.set(provider, null);
+        Field maxSize = 
AbstractLocalizedTextProvider.class.getDeclaredField("i18nCacheMaxSize");
+        maxSize.setAccessible(true);
+        maxSize.setInt(provider, 0);
+
+        ByteArrayOutputStream baos = new ByteArrayOutputStream();
+        try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
+            oos.writeObject(provider);
+        }
+        Object restored;
+        try (ObjectInputStream ois = new ObjectInputStream(new 
ByteArrayInputStream(baos.toByteArray()))) {
+            restored = ois.readObject();
+        }
+
+        StrutsLocalizedTextProvider deserialized = 
(StrutsLocalizedTextProvider) restored;
+        String result = deserialized.findText(CacheFixture.class, 
"cache.static", Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+    }
+
+    public void testCacheTypeSelectionKeepsProviderWorking() {
+        TestStrutsLocalizedTextProvider provider = new 
TestStrutsLocalizedTextProvider();
+        provider.setI18nCacheType("basic");
+        ValueStack valueStack = ActionContext.getContext().getValueStack();
+        String result = provider.findText(CacheFixture.class, "cache.static", 
Locale.ENGLISH, null, null, valueStack);
+        assertEquals("Static cached value", result);
+        assertTrue("bundlesMap should populate", provider.bundlesMapSize() >= 
1);
+    }
+
     @Override
     protected void setUp() throws Exception {
         super.setUp();
diff --git 
a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java 
b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
index 6cce6e601..96e6bd2d1 100644
--- a/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
+++ b/core/src/test/java/org/apache/struts2/dispatcher/DispatcherTest.java
@@ -570,6 +570,46 @@ public class DispatcherTest extends 
StrutsJUnit4InternalTestCase {
         assertEquals(Locale.getDefault(), context.getLocale());  // Expect the 
system default value when Mock request access fails.
     }
 
+    @Test
+    public void testValidateRequestLocaleOffPassesThrough() {
+        initDispatcher(new HashMap<>());
+        dispatcher.setDefaultLocale(null);  // Force struts.locale unset; the 
test-config default would otherwise mask the request locale.
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        // A syntactically valid but not JVM-available locale.
+        Locale exotic = new Locale("en", "US", "xzz99");
+        when(request.getLocale()).thenReturn(exotic);
+
+        assertEquals("Default off must pass the request locale through 
unchanged",
+                exotic, dispatcher.getLocale(request));
+    }
+
+    @Test
+    public void testValidateRequestLocaleOnKeepsAvailableLocale() {
+        Map<String, String> params = new HashMap<>();
+        params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+        initDispatcher(params);
+        dispatcher.setDefaultLocale(null);  // Force struts.locale unset; the 
test-config default would otherwise mask the request locale.
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getLocale()).thenReturn(Locale.UK);
+
+        assertEquals("Available request locale must be kept", Locale.UK, 
dispatcher.getLocale(request));
+    }
+
+    @Test
+    public void testValidateRequestLocaleOnFallsBackForUnavailableLocale() {
+        Map<String, String> params = new HashMap<>();
+        params.put(StrutsConstants.STRUTS_LOCALE_VALIDATE_REQUEST, "true");
+        initDispatcher(params);
+        dispatcher.setDefaultLocale(null);  // Force struts.locale unset; the 
test-config default would otherwise mask the request locale.
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        Locale exotic = new Locale("en", "US", "xzz99");
+        when(request.getLocale()).thenReturn(exotic);
+
+        // struts.locale unset in this dispatcher -> fall back to the JVM 
default.
+        assertEquals("Unavailable request locale must fall back to system 
default",
+                Locale.getDefault(), dispatcher.getLocale(request));
+    }
+
     @Test
     public void dispatcherReinjectedAfterReload() {
         HttpServletRequest request = mock(HttpServletRequest.class);
diff --git 
a/core/src/test/resources/com/opensymphony/xwork2/util/CacheFixture.properties 
b/core/src/test/resources/com/opensymphony/xwork2/util/CacheFixture.properties
new file mode 100644
index 000000000..0e0511b92
--- /dev/null
+++ 
b/core/src/test/resources/com/opensymphony/xwork2/util/CacheFixture.properties
@@ -0,0 +1,19 @@
+#
+# 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.
+#
+cache.static=Static cached value

Reply via email to