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..f39fed35f9b46f 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,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. - utcDateTime = new DateTime(dateTime.Ticks + sourceTimeZone.BaseUtcOffset.Ticks, DateTimeKind.Utc); + // 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); @@ -671,7 +674,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 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); + + // 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() {