From 8a98a9ae4bbbcfff7e9a5b6d188d55f3604c52a5 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed Date: Thu, 16 Apr 2026 09:00:25 -0700 Subject: [PATCH 1/3] Fix TimeZoneInfo.ConvertTime producing wrong results near DateTime.MinValue/MaxValue When converting between time zones where the intermediate UTC value falls outside the DateTime representable range (e.g., converting 0001-01-01 01:13:15 from UTC+4 to UTC+3), the intermediate UTC ticks were clamped to DateTime.MinValue by SafeCreateDateTimeFromTicks. The destination offset was then added to this clamped value, producing an incorrect result. The fix changes TryLocalToUtc to return raw ticks (long) instead of a clamped DateTime. ConvertTime now computes the final result from these raw ticks, only using a clamped DateTime for transition-table offset lookups. This avoids precision loss from double-clamping when the intermediate UTC value is outside DateTime range but the final result is valid. Fixes #126940 --- .../src/System/TimeZoneInfo.Cache.cs | 9 ++--- .../src/System/TimeZoneInfo.cs | 14 +++++-- .../System/TimeZoneInfoTests.cs | 39 +++++++++++++++++++ 3 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs index e45a90401f4cf3..5738bace43ce6b 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.Cache.cs @@ -288,22 +288,21 @@ private bool IsDaylightSavingOn(DateTime localDateTime) /// Tries to convert a local date and time to Coordinated Universal Time (UTC). /// /// The local date and time to convert. - /// When this method returns, contains the UTC date and time if the conversion succeeded. + /// When this method returns, contains the UTC ticks if the conversion succeeded. The value may be negative or exceed DateTime range. /// True if the conversion was successful; otherwise, false. /// /// This method attempts to convert a local time to UTC. It returns false if the local time is invalid, /// such as during a daylight saving time transition when the local time does not exist. /// - private bool TryLocalToUtc(DateTime localDateTime, out DateTime utcDateTime) + private bool TryLocalToUtc(DateTime localDateTime, out long utcTicks) { if (TryGetUtcOffset(localDateTime, out TimeSpan offset)) { - long ticks = localDateTime.Ticks - offset.Ticks; - utcDateTime = SafeCreateDateTimeFromTicks(ticks, DateTimeKind.Utc); + utcTicks = localDateTime.Ticks - offset.Ticks; return true; } - utcDateTime = default; + utcTicks = 0; return false; } diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs index de2234c53d1695..f5e44e89a79ef1 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs @@ -649,7 +649,7 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo throw new ArgumentException(SR.Argument_ConvertMismatch, nameof(sourceTimeZone)); } - bool isInvalidTime = !sourceTimeZone.TryLocalToUtc(dateTime, out DateTime utcDateTime); + bool isInvalidTime = !sourceTimeZone.TryLocalToUtc(dateTime, out long utcTicks); if (((flags & TimeZoneInfoOptions.NoThrowOnInvalidTime) == 0) && isInvalidTime) { @@ -660,7 +660,7 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo { // This is not logical to do but we are keeping it for app compatibility reason. // We get here if the dateTime is invalid in the source time zone. - utcDateTime = new DateTime(dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks, DateTimeKind.Utc); + utcTicks = dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks; } DateTimeKind targetKind = cachedData.GetCorrespondingKind(destinationTimeZone); @@ -671,7 +671,15 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo return dateTime; } - DateTime targetConverted = destinationTimeZone.UtcToLocal(utcDateTime, out bool isDaylightSaving); + // Use a clamped DateTime for destination offset lookup (transition table lookups require a DateTime). + // The raw utcTicks may be outside DateTime range, but the clamped value is sufficient for offset lookup + // because near DateTime.MinValue/MaxValue there are no DST transitions that would differ. + DateTime utcForLookup = SafeCreateDateTimeFromTicks(utcTicks, DateTimeKind.Utc); + TimeSpan destOffset = destinationTimeZone.GetOffsetForUtcDate(utcForLookup, out bool isDaylightSaving); + + // Compute the final result from raw ticks to avoid precision loss from double-clamping. + // The intermediate UTC ticks may be outside DateTime range, but the final local ticks may be valid. + DateTime targetConverted = SafeCreateDateTimeFromTicks(utcTicks + destOffset.Ticks); if (targetKind == DateTimeKind.Local) { diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs index 0e0c488454283a..0067c38562b376 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeZoneInfoTests.cs @@ -290,6 +290,45 @@ public static void ConvertTime_DateTimeOffset_NearMinMaxValue() VerifyConvert(DateTime.MinValue.AddHours(0.5) + earlyTimesDifference, s_strSydney, s_strPacific, DateTime.MinValue.AddHours(0.5)); } + [Theory] + [InlineData(4, 3, 1, 13, 15, 0, 13, 15)] // UTC+4 to UTC+3: subtract 1 hour + [InlineData(5, 3, 2, 30, 0, 0, 30, 0)] // UTC+5 to UTC+3: subtract 2 hours + [InlineData(3, 4, 0, 30, 0, 1, 30, 0)] // UTC+3 to UTC+4: add 1 hour + [InlineData(8, 3, 4, 0, 0, 0, 0, 0)] // UTC+8 to UTC+3: subtract 5 hours, result is MinValue boundary + public static void ConvertTime_DateTime_NearMinValue_PositiveOffsetZones( + int sourceOffsetHours, int destOffsetHours, + int inputHour, int inputMinute, int inputSecond, + int expectedHour, int expectedMinute, int expectedSecond) + { + TimeZoneInfo sourceTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC+{sourceOffsetHours}", TimeSpan.FromHours(sourceOffsetHours), $"UTC+{sourceOffsetHours}", $"UTC+{sourceOffsetHours}"); + TimeZoneInfo destTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC+{destOffsetHours}", TimeSpan.FromHours(destOffsetHours), $"UTC+{destOffsetHours}", $"UTC+{destOffsetHours}"); + + DateTime earlyDate = new DateTime(0001, 01, 01, inputHour, inputMinute, inputSecond); + DateTime converted = TimeZoneInfo.ConvertTime(earlyDate, sourceTimeZone, destTimeZone); + + DateTime expected = new DateTime(0001, 01, 01, expectedHour, expectedMinute, expectedSecond); + Assert.Equal(expected, converted); + } + + [Theory] + [InlineData(-4, -3, 22, 46, 45, 23, 46, 45)] // UTC-4 to UTC-3: add 1 hour + [InlineData(-3, -4, 23, 46, 45, 22, 46, 45)] // UTC-3 to UTC-4: subtract 1 hour + [InlineData(-3, -8, 23, 0, 0, 18, 0, 0)] // UTC-3 to UTC-8: subtract 5 hours + public static void ConvertTime_DateTime_NearMaxValue_NegativeOffsetZones( + int sourceOffsetHours, int destOffsetHours, + int inputHour, int inputMinute, int inputSecond, + int expectedHour, int expectedMinute, int expectedSecond) + { + TimeZoneInfo sourceTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC{sourceOffsetHours}", TimeSpan.FromHours(sourceOffsetHours), $"UTC{sourceOffsetHours}", $"UTC{sourceOffsetHours}"); + TimeZoneInfo destTimeZone = TimeZoneInfo.CreateCustomTimeZone($"UTC{destOffsetHours}", TimeSpan.FromHours(destOffsetHours), $"UTC{destOffsetHours}", $"UTC{destOffsetHours}"); + + DateTime lateDate = new DateTime(9999, 12, 31, inputHour, inputMinute, inputSecond); + DateTime converted = TimeZoneInfo.ConvertTime(lateDate, sourceTimeZone, destTimeZone); + + DateTime expected = new DateTime(9999, 12, 31, expectedHour, expectedMinute, expectedSecond); + Assert.Equal(expected, converted); + } + [Fact] public static void ConvertTime_DateTimeOffset_VariousSystemTimeZones() { From 69b87ff90fc1f1e7295133905e162c4962abec5f Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:11:04 -0700 Subject: [PATCH 2/3] Update src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../System.Private.CoreLib/src/System/TimeZoneInfo.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs index f5e44e89a79ef1..71c12dcc0941d9 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs @@ -671,9 +671,9 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo return dateTime; } - // Use a clamped DateTime for destination offset lookup (transition table lookups require a DateTime). - // The raw utcTicks may be outside DateTime range, but the clamped value is sufficient for offset lookup - // because near DateTime.MinValue/MaxValue there are no DST transitions that would differ. + // Use a clamped DateTime for destination offset lookup because transition-table lookups require + // an in-range DateTime. This preserves the existing offset-selection behavior for the lookup, + // while the final local ticks are still computed from the raw utcTicks to avoid double-clamping. DateTime utcForLookup = SafeCreateDateTimeFromTicks(utcTicks, DateTimeKind.Utc); TimeSpan destOffset = destinationTimeZone.GetOffsetForUtcDate(utcForLookup, out bool isDaylightSaving); From 1dd7c1fb329a9514d161bf61c3125855078c73a9 Mon Sep 17 00:00:00 2001 From: Tarek Mahmoud Sayed <10833894+tarekgh@users.noreply.github.com> Date: Thu, 16 Apr 2026 09:39:03 -0700 Subject: [PATCH 3/3] Update src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../System.Private.CoreLib/src/System/TimeZoneInfo.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs index 71c12dcc0941d9..f39fed35f9b46f 100644 --- a/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs +++ b/src/libraries/System.Private.CoreLib/src/System/TimeZoneInfo.cs @@ -660,7 +660,10 @@ private static DateTime ConvertTime(DateTime dateTime, TimeZoneInfo sourceTimeZo { // This is not logical to do but we are keeping it for app compatibility reason. // We get here if the dateTime is invalid in the source time zone. - utcTicks = dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks; + // Preserve the historical behavior of throwing if the computed UTC time is + // outside the DateTime range, rather than silently clamping it later. + DateTime invalidTimeUtc = new DateTime(dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks, DateTimeKind.Utc); + utcTicks = invalidTimeUtc.Ticks; } DateTimeKind targetKind = cachedData.GetCorrespondingKind(destinationTimeZone);