From e5a534ab19d540cd182628c2a413522a5d0feb5d Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Mon, 25 May 2026 15:41:26 +0530 Subject: [PATCH 1/4] fix(#4129): apply locale when timezone argument is omitted in DatePatternConverter From 31e6e23f673ecfa161b90f4f10bf86b4732e9f9b Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Mon, 25 May 2026 23:42:49 +0530 Subject: [PATCH 2/4] Fix `DatePatternConverter` to correctly apply locale when timezone is omitted --- .../pattern/DatePatternConverterTestBase.java | 137 ++++++++++++++++++ .../core/pattern/DatePatternConverter.java | 45 +++++- ...tternConverter_locale_without_timezone.xml | 14 ++ 3 files changed, 194 insertions(+), 2 deletions(-) create mode 100644 src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java index 16fd89ac30b..236a5d109aa 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java @@ -17,7 +17,9 @@ package org.apache.logging.log4j.core.pattern; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import java.text.SimpleDateFormat; import java.time.ZoneId; @@ -25,6 +27,7 @@ import java.time.temporal.TemporalAccessor; import java.util.Calendar; import java.util.Date; +import java.util.Locale; import java.util.TimeZone; import java.util.stream.Stream; import org.apache.logging.log4j.core.AbstractLogEvent; @@ -36,6 +39,8 @@ import org.junit.jupiter.api.Test; abstract class DatePatternConverterTestBase { + // Mid-month instant to keep month-name assertions stable across all time zones. + private static final long LOCALE_TEST_EPOCH_MILLIS = 1705276800000L; private static final class MyLogEvent extends AbstractLogEvent { private static final long serialVersionUID = 0; @@ -352,4 +357,136 @@ void testPredefinedFormatWithTimezone() { assertEquals(expectedPattern, converter.getPattern()); } } + + /** + * Helper: format a fixed UTC instant through the given options. + * The epoch is 2024-01-01T00:00:00Z: January / Januar depending on locale. + */ + private static String formatLocaleInstant(final String... options) { + final MutableInstant instant = new MutableInstant(); + instant.initFromEpochMilli(LOCALE_TEST_EPOCH_MILLIS, 0); + final DatePatternConverter converter = DatePatternConverter.newInstance(options); + final StringBuilder sb = new StringBuilder(); + converter.format(instant, sb); + return sb.toString(); + } + + @Test + void testIsLocaleOptionDetectsLocaleWithRegion() { + assertTrue(DatePatternConverter.isLocaleOption("de-DE")); + assertTrue(DatePatternConverter.isLocaleOption("fr-FR")); + assertTrue(DatePatternConverter.isLocaleOption("zh-Hans-CN")); + assertTrue(DatePatternConverter.isLocaleOption("en-US")); + } + + @Test + void testIsLocaleOptionDetectsLanguageOnlyLocale() { + assertTrue(DatePatternConverter.isLocaleOption("de")); + assertTrue(DatePatternConverter.isLocaleOption("fr")); + assertTrue(DatePatternConverter.isLocaleOption("zh")); + } + + @Test + void testIsLocaleOptionRejectsTimezones() { + assertFalse(DatePatternConverter.isLocaleOption("UTC")); + assertFalse(DatePatternConverter.isLocaleOption("GMT")); + assertFalse(DatePatternConverter.isLocaleOption("JST")); + assertFalse(DatePatternConverter.isLocaleOption("PST")); + assertFalse(DatePatternConverter.isLocaleOption("utc")); + assertFalse(DatePatternConverter.isLocaleOption("gmt")); + assertFalse(DatePatternConverter.isLocaleOption("pst")); + assertFalse(DatePatternConverter.isLocaleOption("America/New_York")); + assertFalse(DatePatternConverter.isLocaleOption("Etc/GMT+5")); + assertFalse(DatePatternConverter.isLocaleOption("GB-Eire")); + assertFalse(DatePatternConverter.isLocaleOption("gb-eire")); + assertFalse(DatePatternConverter.isLocaleOption("NZ-CHAT")); + assertFalse(DatePatternConverter.isLocaleOption(null)); + } + + @Test + void testHyphenatedTimezonesAreNotTreatedAsLocales() { + assertEquals( + TimeZone.getTimeZone("GB-Eire"), + DatePatternConverter.newInstance(new String[] {"ISO8601", "GB-Eire"}) + .getTimeZone()); + assertEquals( + TimeZone.getTimeZone("NZ-CHAT"), + DatePatternConverter.newInstance(new String[] {"ISO8601", "NZ-CHAT"}) + .getTimeZone()); + } + + /** + * %d{dd-MMMM-yyyy}{GMT}{de-DE}: three args, always worked. + * Verify baseline still produces German month name. + */ + @Test + void testLocaleAppliedWhenTimezoneAndLocaleBothProvided() { + final String result = formatLocaleInstant("dd-MMMM-yyyy", "GMT", "de-DE"); + assertTrue( + result.contains("Januar"), + () -> "Expected German month 'Januar' with [pattern][GMT][de-DE], got: " + result); + } + + /** + * Locale-only argument should work generally across multiple locales. + */ + @Test + void testLocaleAppliedWhenTimezoneOmitted_monthName_multipleLocales() { + final String[][] localeCases = { + {"de-DE", "januar"}, + {"fr-FR", "janvier"}, + {"es-ES", "enero"}, + {"it-IT", "gennaio"}, + {"pt-BR", "janeiro"} + }; + for (final String[] localeCase : localeCases) { + final String localeTag = localeCase[0]; + final String expectedMonth = localeCase[1]; + final String result = formatLocaleInstant("dd-MMMM-yyyy", localeTag); + final String lowerCaseResult = result.toLowerCase(Locale.ROOT); + assertTrue( + lowerCaseResult.contains(expectedMonth), + () -> "Expected month '" + expectedMonth + "' with [pattern][" + localeTag + + "] (no timezone), got: " + result); + assertFalse( + lowerCaseResult.contains("january"), + () -> "locale '" + localeTag + "' must not produce English 'January', got: " + result); + } + } + + @Test + void testLocaleAppliedWhenTimezoneOmitted_languageOnlyTag() { + final String result = formatLocaleInstant("dd-MMMM-yyyy", "de"); + assertTrue( + result.contains("Januar"), + () -> "Expected German month 'Januar' with [pattern][de] (no timezone), got: " + result); + assertFalse(result.contains("January"), () -> "locale 'de' must not produce English 'January', got: " + result); + } + + /** + * %d{EEEE, dd. MMMM yyyy}{de-DE}: full date with day-of-week and locale only. + * Regression: locale was silently ignored. + * Fix: German day-of-week and month must be used. + * See: https://github.com/apache/logging-log4j2/issues/4129 + */ + @Test + void testLocaleAppliedWhenTimezoneOmitted_fullDate() { + final String result = formatLocaleInstant("EEEE, dd. MMMM yyyy", "de-DE"); + assertTrue( + result.contains("Januar"), + () -> "Expected German month 'Januar' with full-date pattern + de-DE locale, got: " + result); + assertTrue( + result.contains("Montag"), + () -> "Expected German day-of-week 'Montag' with full-date pattern + de-DE locale, got: " + result); + } + + /** + * When second arg is a locale tag, the effective timezone must remain the JVM default: + * NOT be interpreted as the bogus GMT that {@code TimeZone.getTimeZone("de-DE")} returns. + */ + @Test + void testDefaultTimezoneUsedWhenOnlyLocaleProvided() { + final DatePatternConverter converter = DatePatternConverter.newInstance(new String[] {"ISO8601", "de-DE"}); + assertEquals(TimeZone.getDefault(), converter.getTimeZone()); + } } diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java index 8b598c3de85..cfce19ba9a5 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java @@ -21,7 +21,9 @@ import java.time.format.DateTimeFormatter; import java.util.Arrays; import java.util.Date; +import java.util.HashSet; import java.util.Locale; +import java.util.Set; import java.util.TimeZone; import java.util.stream.Collectors; import org.apache.commons.lang3.time.FastDateFormat; @@ -49,6 +51,13 @@ public final class DatePatternConverter extends LogEventPatternConverter impleme private static final String CLASS_NAME = DatePatternConverter.class.getSimpleName(); + private static final Set AVAILABLE_TIME_ZONE_IDS = new HashSet<>(Arrays.asList(TimeZone.getAvailableIDs())); + private static final Set UPPERCASE_TIME_ZONE_IDS = AVAILABLE_TIME_ZONE_IDS.stream() + .map(id -> id.toUpperCase(Locale.ROOT)) + .collect(Collectors.toSet()); + private static final String LOCALE_LANGUAGE_ONLY_PATTERN = "[a-zA-Z]{2}"; + private static final String LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN = "[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,8})+"; + private final InstantFormatter formatter; private DatePatternConverter(@Nullable final String[] options) { @@ -123,7 +132,7 @@ static String decodeNamedPattern(final String pattern) { private static TimeZone readTimeZone(@Nullable final String[] options) { try { - if (options != null && options.length > 1 && options[1] != null) { + if (options != null && options.length > 1 && options[1] != null && !isLocaleOption(options[1])) { return TimeZone.getTimeZone(options[1]); } } catch (final Exception error) { @@ -140,15 +149,47 @@ private static Locale readLocale(@Nullable final String[] options) { if (options != null && options.length > 2 && options[2] != null) { return Locale.forLanguageTag(options[2]); } + if (options != null && options.length > 1 && options[1] != null && isLocaleOption(options[1])) { + return Locale.forLanguageTag(options[1]); + } } catch (final Exception error) { logOptionReadFailure( options, error, - "failed to read the locale at index 2 of options: {}, falling back to the default locale"); + "failed to read the locale at index 1 or 2 of options: {}, falling back to the default locale"); } return Locale.getDefault(); } + /** + * Returns {@code true} if the given string looks like a locale tag and is not a timezone ID. + * + *

Supported locale forms include language-only tags ({@code de}), full BCP 47 tags ({@code de-DE}, + * {@code zh-Hans-CN}), and locale strings using underscore separators ({@code de_DE}). Time zone IDs + * always take precedence, including legacy IDs such as {@code GB-Eire} and case variants like {@code utc}.

+ * + * @param value the string to test + * @return {@code true} if {@code value} is recognized as a locale tag and not as a timezone ID + */ + static boolean isLocaleOption(@Nullable final String value) { + if (value == null || isTimeZoneOption(value)) { + return false; + } + final boolean localeShape = + value.matches(LOCALE_LANGUAGE_ONLY_PATTERN) || value.matches(LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN); + if (!localeShape) { + return false; + } + final Locale locale = Locale.forLanguageTag(value.replace('_', '-')); + final String language = locale.getLanguage(); + return !language.isEmpty() && !"und".equals(language); + } + + private static boolean isTimeZoneOption(final String value) { + return AVAILABLE_TIME_ZONE_IDS.contains(value) + || UPPERCASE_TIME_ZONE_IDS.contains(value.toUpperCase(Locale.ROOT)); + } + private static void logOptionReadFailure(final String[] options, final Exception error, final String message) { if (LOGGER.isWarnEnabled()) { final String quotedOptions = diff --git a/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml b/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml new file mode 100644 index 00000000000..2ed7b364ee6 --- /dev/null +++ b/src/changelog/.2.x.x/LOG4J2-4129_Fix_DatePatternConverter_locale_without_timezone.xml @@ -0,0 +1,14 @@ + + + + + Fix `DatePatternConverter` to treat `%d{pattern}{de-DE}` as locale when timezone is omitted. + `%d{pattern}{timezone}{locale}` behavior is unchanged. + + + From 8747cff87e354ff00df8e204be1b06e010bb042b Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Tue, 26 May 2026 17:20:21 +0530 Subject: [PATCH 3/4] Refactor `DatePatternConverter` to use regex patterns for locale validation and add settings for editor configuration --- .../log4j/core/pattern/DatePatternConverter.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java index cfce19ba9a5..817a58c2aa3 100644 --- a/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java +++ b/log4j-core/src/main/java/org/apache/logging/log4j/core/pattern/DatePatternConverter.java @@ -25,6 +25,7 @@ import java.util.Locale; import java.util.Set; import java.util.TimeZone; +import java.util.regex.Pattern; import java.util.stream.Collectors; import org.apache.commons.lang3.time.FastDateFormat; import org.apache.logging.log4j.core.LogEvent; @@ -55,8 +56,9 @@ public final class DatePatternConverter extends LogEventPatternConverter impleme private static final Set UPPERCASE_TIME_ZONE_IDS = AVAILABLE_TIME_ZONE_IDS.stream() .map(id -> id.toUpperCase(Locale.ROOT)) .collect(Collectors.toSet()); - private static final String LOCALE_LANGUAGE_ONLY_PATTERN = "[a-zA-Z]{2}"; - private static final String LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN = "[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,8})+"; + private static final Pattern LOCALE_LANGUAGE_ONLY_PATTERN = Pattern.compile("[a-zA-Z]{2}"); + private static final Pattern LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN = + Pattern.compile("[a-zA-Z]{2,3}([-_][a-zA-Z0-9]{2,8})+"); private final InstantFormatter formatter; @@ -175,8 +177,8 @@ static boolean isLocaleOption(@Nullable final String value) { if (value == null || isTimeZoneOption(value)) { return false; } - final boolean localeShape = - value.matches(LOCALE_LANGUAGE_ONLY_PATTERN) || value.matches(LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN); + final boolean localeShape = LOCALE_LANGUAGE_ONLY_PATTERN.matcher(value).matches() + || LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN.matcher(value).matches(); if (!localeShape) { return false; } From e42bb10822029c17b15a83ebb28a69d244f4d925 Mon Sep 17 00:00:00 2001 From: Ramanathan Date: Tue, 26 May 2026 17:21:22 +0530 Subject: [PATCH 4/4] Refactor `DatePatternConverter` to use regex patterns for locale validation and add settings for editor configuration --- .../logging/log4j/core/pattern/DatePatternConverterTestBase.java | 1 - 1 file changed, 1 deletion(-) diff --git a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java index 236a5d109aa..386217a08a7 100644 --- a/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java +++ b/log4j-core-test/src/test/java/org/apache/logging/log4j/core/pattern/DatePatternConverterTestBase.java @@ -39,7 +39,6 @@ import org.junit.jupiter.api.Test; abstract class DatePatternConverterTestBase { - // Mid-month instant to keep month-name assertions stable across all time zones. private static final long LOCALE_TEST_EPOCH_MILLIS = 1705276800000L; private static final class MyLogEvent extends AbstractLogEvent {