Skip to content
Merged
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 @@ -495,6 +495,65 @@ private int FindRuleForYear(int year)
return ruleIndex;
}

/// <summary>
/// Gets the standard (non-daylight) UTC offset ticks that apply to the specified local time.
/// This is the zone's base UTC offset combined with the applicable rule's <see cref="AdjustmentRule.BaseUtcOffsetDelta"/>,
/// and is used to convert an invalid (DST gap) local time to UTC so that zones which changed their
/// standard offset over time (a non-zero BaseUtcOffsetDelta) are handled correctly.
/// </summary>
/// <param name="localDateTime">The local time that falls within a daylight saving gap.</param>
/// <returns>The standard UTC offset ticks to subtract from the local time.</returns>
private long GetStandardUtcOffsetTicks(DateTime localDateTime)
{
int ruleIndex = FindRuleIndexForLocalTime(localDateTime);
return ruleIndex < 0
? _baseUtcOffset.Ticks
: GetTransitionUtcOffsetTicks(_adjustmentRules![ruleIndex], includeDaylightDelta: false);
}

/// <summary>
/// Finds the index of the adjustment rule that applies to the specified local time, resolving the rule
/// adjacent to the exact timestamp rather than by calendar year. This matters for zones whose standard
/// offset changed between two rules within the same year (for example southern-hemisphere zones that
/// begin a year in daylight saving time), where a year-based lookup could return the wrong rule.
/// </summary>
/// <param name="localDateTime">The local time to resolve.</param>
/// <returns>The index of the applicable adjustment rule, or -1 if none applies.</returns>
private int FindRuleIndexForLocalTime(DateTime localDateTime)
{
AdjustmentRule[]? rules = _adjustmentRules;
if (rules is null || rules.Length == 0)
{
return -1;
}

DateTime date = localDateTime.Date;
int low = 0;
int high = rules.Length - 1;
while (low <= high)
{
int median = low + (high - low) / 2;
AdjustmentRule rule = rules[median];
AdjustmentRule previousRule = median > 0 ? rules[median - 1] : rule;
int compareResult = CompareAdjustmentRuleToDateTime(rule, previousRule, localDateTime, date, dateTimeIsUtc: false);
if (compareResult == 0)
{
return median;
}

if (compareResult < 0)
{
low = median + 1;
}
else
{
high = median - 1;
}
}

return -1;
}

/// <summary>
/// Grows the given pool array to accommodate the required capacity.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,10 @@ private TimeSpan GetUtcOffset(DateTime dateTime, TimeZoneInfoOptions flags, Cach
}
}

return TryGetUtcOffset(dateTime, out TimeSpan offset) ? offset : _baseUtcOffset;
// For an invalid (DST gap) local time TryGetUtcOffset returns false. Fall back to the standard
// offset (base plus the applicable rule's BaseUtcOffsetDelta) so GetUtcOffset and DateTimeOffset
// stay consistent with ConvertTime/ToUniversalTime for zones that changed their standard offset.
return TryGetUtcOffset(dateTime, out TimeSpan offset) ? offset : new TimeSpan(GetStandardUtcOffsetTicks(dateTime));
}

/// <summary>
Expand Down Expand Up @@ -660,9 +663,11 @@ 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.
// Subtract the standard offset (base UTC offset plus the applicable rule's BaseUtcOffsetDelta)
// so zones that changed their standard offset over time convert correctly.
// 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);
DateTime invalidTimeUtc = new DateTime(dateTime.Ticks - sourceTimeZone.GetStandardUtcOffsetTicks(dateTime), DateTimeKind.Utc);
utcTicks = invalidTimeUtc.Ticks;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3322,6 +3322,48 @@ public static void IsInvalidTimeTestOnLinux(DateTime testTime, string timeZoneId
Assert.Equal(expectedIsInvalid, timeZone.IsInvalidTime(testTime));
}

public static TheoryData<string, DateTime, DateTime> InvalidLocalTimeToUniversalTimeData => new()
{
// Spring-forward gap (invalid local times); expected UTC subtracts the standard offset.
// Each row lists the time zone, the invalid local wall-clock time, and the expected UTC result.

// UTC+1 standard offset.
{ "Europe/Berlin", new DateTime(2026, 3, 29, 2, 30, 0), new DateTime(2026, 3, 29, 1, 30, 0) },
// UTC+0 standard offset.
{ "Europe/Lisbon", new DateTime(2026, 3, 29, 1, 30, 0), new DateTime(2026, 3, 29, 1, 30, 0) },
{ "Europe/London", new DateTime(2026, 3, 29, 1, 30, 0), new DateTime(2026, 3, 29, 1, 30, 0) },
// Negative (behind UTC) standard offset: EST is UTC-5, so UTC is ahead of the local wall clock.
{ "America/New_York", new DateTime(2026, 3, 8, 2, 30, 0), new DateTime(2026, 3, 8, 7, 30, 0) },
};

[ConditionalTheory(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))]
[PlatformSpecific(TestPlatforms.AnyUnix)]
[MemberData(nameof(InvalidLocalTimeToUniversalTimeData))]
public static void ToUniversalTime_InvalidLocalTime_SubtractsStandardOffset(string timeZoneId, DateTime wallClock, DateTime expectedUtcWallClock)
{
// Regression test for https://github.com/dotnet/runtime/issues/134846.
// For a Local DateTime in the spring-forward gap (an invalid time), ToUniversalTime()
// must subtract the standard UTC offset. The expected result is asserted against an
// independent literal value and cross-checked with GetUtcOffset and DateTimeOffset.
RemoteExecutor.Invoke(static (id, ticks, expectedTicks) =>
{
Environment.SetEnvironmentVariable("TZ", id);
TimeZoneInfo.ClearCachedData();

TimeZoneInfo local = TimeZoneInfo.Local;
DateTime invalidLocal = DateTime.SpecifyKind(new DateTime(long.Parse(ticks)), DateTimeKind.Local);
DateTime expectedUtc = new DateTime(long.Parse(expectedTicks), DateTimeKind.Utc);
Assert.True(local.IsInvalidTime(invalidLocal), $"Expected an invalid time for '{id}'.");

Assert.Equal(expectedUtc, invalidLocal.ToUniversalTime());
Assert.Equal(expectedUtc, new DateTimeOffset(invalidLocal).UtcDateTime);

// The subtracted offset must match the standard (non-daylight) offset reported for this time.
TimeSpan offset = local.GetUtcOffset(invalidLocal);
Assert.Equal(expectedUtc, new DateTime(invalidLocal.Ticks - offset.Ticks, DateTimeKind.Utc));
}, timeZoneId, wallClock.Ticks.ToString(), expectedUtcWallClock.Ticks.ToString()).Dispose();
}

public static TheoryData<DateTime, string, bool> AmbiguousTimeTestData => new()
{
// DST end in Berlin (ambiguous)
Expand Down
Loading