fix(worker): guard timestamp-format derivation against pathological culture patterns
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user