Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@
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;
import java.time.format.DateTimeFormatter;
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;
Expand All @@ -36,6 +39,7 @@
import org.junit.jupiter.api.Test;

abstract class DatePatternConverterTestBase {
private static final long LOCALE_TEST_EPOCH_MILLIS = 1705276800000L;

private static final class MyLogEvent extends AbstractLogEvent {
private static final long serialVersionUID = 0;
Expand Down Expand Up @@ -352,4 +356,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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,11 @@
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.regex.Pattern;
import java.util.stream.Collectors;
import org.apache.commons.lang3.time.FastDateFormat;
import org.apache.logging.log4j.core.LogEvent;
Expand All @@ -49,6 +52,14 @@ public final class DatePatternConverter extends LogEventPatternConverter impleme

private static final String CLASS_NAME = DatePatternConverter.class.getSimpleName();

private static final Set<String> AVAILABLE_TIME_ZONE_IDS = new HashSet<>(Arrays.asList(TimeZone.getAvailableIDs()));
private static final Set<String> UPPERCASE_TIME_ZONE_IDS = AVAILABLE_TIME_ZONE_IDS.stream()
.map(id -> id.toUpperCase(Locale.ROOT))
.collect(Collectors.toSet());
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;

private DatePatternConverter(@Nullable final String[] options) {
Expand Down Expand Up @@ -123,7 +134,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) {
Expand All @@ -140,15 +151,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.
*
* <p>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}.</p>
*
* @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 = LOCALE_LANGUAGE_ONLY_PATTERN.matcher(value).matches()
|| LOCALE_LANGUAGE_WITH_SUBTAGS_PATTERN.matcher(value).matches();
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 =
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<entry xmlns="https://logging.apache.org/xml/ns"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
https://logging.apache.org/xml/ns
https://logging.apache.org/xml/ns/log4j-changelog-0.xsd"
type="fixed">
<issue id="4129" link="https://github.com/apache/logging-log4j2/issues/4129"/>
<description format="asciidoc">
Fix `DatePatternConverter` to treat `%d{pattern}{de-DE}` as locale when timezone is omitted.
`%d{pattern}{timezone}{locale}` behavior is unchanged.
</description>
</entry>