fix(worker): guard timestamp-format derivation against pathological culture patterns
This commit is contained in:
@@ -303,6 +303,55 @@ public sealed class MxAccessEventMapperTests
|
|||||||
Assert.Equal(DateTime.SpecifyKind(localWall, DateTimeKind.Local).ToUniversalTime(), utc);
|
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>
|
/// <summary>
|
||||||
/// The parse chain exactly as it stood before PERF-20 added the
|
/// The parse chain exactly as it stood before PERF-20 added the
|
||||||
/// exact-format stage, used as the parity oracle above.
|
/// exact-format stage, used as the parity oracle above.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
using Google.Protobuf.WellKnownTypes;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
@@ -15,7 +16,13 @@ public sealed class MxAccessEventMapper
|
|||||||
/// <see cref="TryParseSourceTimestamp"/> tries before the general parse
|
/// <see cref="TryParseSourceTimestamp"/> tries before the general parse
|
||||||
/// chain. Keyed by <see cref="CultureInfo"/> because the format set is
|
/// chain. Keyed by <see cref="CultureInfo"/> because the format set is
|
||||||
/// derived from the culture's own patterns and the worker host's culture
|
/// 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>
|
/// </summary>
|
||||||
private static readonly ConcurrentDictionary<CultureInfo, string[]> TimestampFormatCache = new();
|
private static readonly ConcurrentDictionary<CultureInfo, string[]> TimestampFormatCache = new();
|
||||||
|
|
||||||
@@ -397,8 +404,12 @@ public sealed class MxAccessEventMapper
|
|||||||
|
|
||||||
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
|
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
|
||||||
CultureInfo culture = CultureInfo.CurrentCulture;
|
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);
|
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, culture, styles, out parsed)
|
||||||
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, 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
|
/// <c>.fff</c> after the seconds specifier; a pattern without a
|
||||||
/// two-digit seconds specifier, or one that already carries a fraction,
|
/// two-digit seconds specifier, or one that already carries a fraction,
|
||||||
/// gets only the published pattern.
|
/// 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>
|
/// </summary>
|
||||||
/// <param name="culture">Culture whose patterns the formats are derived from.</param>
|
/// <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)
|
private static string[] BuildTimestampFormats(CultureInfo culture)
|
||||||
{
|
{
|
||||||
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
|
DateTimeFormatInfo formatInfo = culture.DateTimeFormat;
|
||||||
string shortDate = formatInfo.ShortDatePattern;
|
string shortDate = formatInfo.ShortDatePattern;
|
||||||
string longTime = formatInfo.LongTimePattern;
|
string longTime = formatInfo.LongTimePattern;
|
||||||
|
List<string> formats = new(2);
|
||||||
|
|
||||||
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
|
int secondsIndex = longTime.IndexOf("ss", StringComparison.Ordinal);
|
||||||
if (secondsIndex >= 0 && longTime.IndexOf('f') < 0 && longTime.IndexOf('F') < 0)
|
if (secondsIndex >= 0 && longTime.IndexOf('f') < 0 && longTime.IndexOf('F') < 0)
|
||||||
{
|
{
|
||||||
return
|
string withMilliseconds = shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff");
|
||||||
[
|
if (RoundTripsProbe(withMilliseconds, culture, new DateTime(2026, 3, 26, 13, 38, 22, 907)))
|
||||||
shortDate + " " + longTime.Insert(secondsIndex + 2, ".fff"),
|
{
|
||||||
shortDate + " " + longTime,
|
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(
|
private MxArray ConvertBufferedArray(
|
||||||
|
|||||||
Reference in New Issue
Block a user