fix(worker): guard timestamp-format derivation against pathological culture patterns

This commit is contained in:
Joseph Doherty
2026-08-15 17:10:58 -04:00
parent 13583322b5
commit 25cbe5cd3e
2 changed files with 122 additions and 9 deletions
@@ -303,6 +303,55 @@ public sealed class MxAccessEventMapperTests
Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
}
/// <summary>
/// Verifies format derivation survives a culture whose long-time pattern
/// hides an "ss" inside quoted literal text — the case where blindly
/// inserting ".fff" at the first "ss" produces a format that mangles the
/// value or is outright malformed, and a malformed format makes exact
/// parsing throw rather than report failure. Derivation must reject such
/// a candidate at build time, so parsing here stays exception-free and
/// the culture's own published shape still reads as local wall-clock
/// time. Uses a culture no other test touches, because the format cache
/// compares cultures by name.
/// </summary>
[Fact]
public void TryParseSourceTimestamp_WithLiteralSecondsInCulturePattern_ParsesWithoutThrowing()
{
CultureInfo pathological = new("en-ZA")
{
DateTimeFormat =
{
ShortDatePattern = "M/d/yyyy",
LongTimePattern = "'sss' HH:mm:ss",
},
};
CultureInfo original = CultureInfo.CurrentCulture;
try
{
CultureInfo.CurrentCulture = pathological;
DateTime localWall = new(2026, 3, 26, 13, 38, 22, DateTimeKind.Unspecified);
// The culture's published shape: "3/26/2026 sss 13:38:22".
string published = localWall.ToString(
pathological.DateTimeFormat.ShortDatePattern + " " + pathological.DateTimeFormat.LongTimePattern,
pathological);
Assert.True(MxAccessEventMapper.TryParseSourceTimestamp(published, out DateTime utc));
Assert.Equal(DateTimeKind.Utc, utc.Kind);
Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
// Shapes this culture cannot describe must still fail quietly rather
// than throw out of the derived formats.
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("not a timestamp", out _));
Assert.False(MxAccessEventMapper.TryParseSourceTimestamp("sss sss sss", out _));
}
finally
{
CultureInfo.CurrentCulture = original;
}
}
/// <summary>
/// The parse chain exactly as it stood before PERF-20 added the
/// exact-format stage, used as the parity oracle above.
@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
@@ -15,7 +16,13 @@ public sealed class MxAccessEventMapper
/// <see cref="TryParseSourceTimestamp"/> tries before the general parse
/// chain. Keyed by <see cref="CultureInfo"/> because the format set is
/// derived from the culture's own patterns and the worker host's culture
/// is fixed in practice, so the derivation runs once.
/// is fixed in practice, so the derivation runs once. The cache assumes
/// <see cref="DateTimeFormatInfo"/> patterns are not customized or
/// mutated at runtime — <see cref="CultureInfo"/> compares by name, so a
/// culture carrying custom patterns is served the formats derived for its
/// stock namesake. That direction is safe: a stale format simply stops
/// matching, and the unchanged general chain below still parses the
/// string.
/// </summary>
private static readonly ConcurrentDictionary<CultureInfo, string[]> TimestampFormatCache = new();
@@ -397,8 +404,12 @@ public sealed class MxAccessEventMapper
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
CultureInfo culture = CultureInfo.CurrentCulture;
// Empty when the culture's patterns yielded no format that survived
// validation; that culture just gets no fast path.
string[] formats = TimestampFormatCache.GetOrAdd(culture, TimestampFormatFactory);
if (DateTime.TryParseExact(text, formats, culture, styles, out DateTime parsed)
DateTime parsed;
if ((formats.Length > 0 && DateTime.TryParseExact(text, formats, culture, styles, out parsed))
|| DateTime.TryParse(text, culture, styles, out parsed)
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
{
@@ -419,25 +430,78 @@ public sealed class MxAccessEventMapper
/// <c>.fff</c> after the seconds specifier; a pattern without a
/// two-digit seconds specifier, or one that already carries a fraction,
/// gets only the published pattern.
/// <para>
/// Both candidates are validated by round-tripping a probe instant
/// before they are handed to the event path, because the derivation
/// reads patterns it does not control: the first <c>ss</c> in a
/// pattern could in principle sit inside quoted literal text, and
/// inserting into it would produce a format that either mangles the
/// value or is outright malformed — and a malformed format makes
/// <see cref="DateTime.ParseExact(string, string, IFormatProvider)"/>
/// and its siblings throw <see cref="FormatException"/> rather than
/// report failure. Validation happens here, once per culture, so the
/// event path never carries that risk; a candidate that does not
/// round-trip is dropped, and a culture that loses both simply gets
/// no fast path.
/// </para>
/// </summary>
/// <param name="culture">Culture whose patterns the formats are derived from.</param>
/// <returns>The formats to try, most likely first.</returns>
/// <returns>The validated formats to try, most likely first; empty when none survived.</returns>
private static string[] BuildTimestampFormats(CultureInfo culture)
{
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
string shortDate = formatInfo.ShortDatePattern;
string longTime = formatInfo.LongTimePattern;
List<string> formats = new(2);
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
if (secondsIndex >= 0 && longTime.IndexOf('f') < 0 && longTime.IndexOf('F') < 0)
{
return
[
shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff"),
shortDate + " " + longTime,
];
string withMilliseconds = shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff");
if (RoundTripsProbe(withMilliseconds, culture, new DateTime(2026, 3, 26, 13, 38, 22, 907)))
{
formats.Add(withMilliseconds);
}
}
return [shortDate + " " + longTime];
string published = shortDate + " " + longTime;
if (RoundTripsProbe(published, culture, new DateTime(2026, 3, 26, 13, 38, 22)))
{
formats.Add(published);
}
return formats.ToArray();
}
/// <summary>
/// Reports whether a derived format both formats and re-parses a probe
/// instant back to itself under the given culture. A format that throws,
/// fails to parse its own output, or loses part of the instant is
/// rejected — the fast path may only ever be a shortcut.
/// </summary>
/// <param name="format">Candidate format to validate.</param>
/// <param name="culture">Culture the format was derived from.</param>
/// <param name="probe">Instant to round-trip; carries the precision the format must preserve.</param>
/// <returns><see langword="true"/> when the format is safe to use on the event path.</returns>
private static bool RoundTripsProbe(
string format,
CultureInfo culture,
DateTime probe)
{
try
{
string probeText = probe.ToString(format, culture);
return DateTime.TryParseExact(probeText, format, culture, DateTimeStyles.None, out DateTime roundTripped)
&& roundTripped == probe;
}
catch (FormatException)
{
return false;
}
catch (ArgumentException)
{
return false;
}
}
private MxArray ConvertBufferedArray(