From 2aca65db1498e483f646551d9d822365fba37a4f Mon Sep 17 00:00:00 2001 From: Simon Cropp Date: Mon, 17 Aug 2026 20:26:33 +1000 Subject: [PATCH] Escape one character trimmed date formats Formats ending in an upper case fraction get a second scrubber built for the trimmed format, but a one character format string is a standard format specifier rather than the custom one it was written as. So ScrubInlineDateTimes("s.F") trimmed to "s", the sortable pattern, and scrubbed every full sortable date-time in the output, while "H.F" trimmed to "H" and threw "Invalid format: H" at registration despite passing the up-front validation. The trimmed format is now escaped with `%` when a single character is left, and no second scrubber is built when nothing is left at all. --- .../TrimmedFractionTests.cs | 41 +++++++++++++++++++ .../Serialization/Scrubbers/DateMatchers.cs | 41 +++++++++++++------ src/todo.md | 2 +- 3 files changed, 70 insertions(+), 14 deletions(-) create mode 100644 src/ApplyScrubbersTests/TrimmedFractionTests.cs diff --git a/src/ApplyScrubbersTests/TrimmedFractionTests.cs b/src/ApplyScrubbersTests/TrimmedFractionTests.cs new file mode 100644 index 000000000..4785b2cd0 --- /dev/null +++ b/src/ApplyScrubbersTests/TrimmedFractionTests.cs @@ -0,0 +1,41 @@ +// Formats ending in an upper case fraction produce a second scrubber for the trimmed +// format, since those fractions render as empty when zero. A one character format +// string is a standard format specifier, so the trimmed format has to be escaped. +public class TrimmedFractionTests +{ + static readonly CultureInfo enUs = new("en-US"); + + [Fact] + public void SingleCharTrimmedFormatDoesNotBecomeStandardFormat() + { + var scrubbers = DateMatchers.DateTimes("s.F", enUs); + + // "s" as a standard format is the sortable pattern, so the trimmed scrubber + // used to swallow every full sortable date-time in the output + var result = EngineRunner.Run("2020-01-01T10:20:30", scrubbers); + Assert.NotEqual("DateTime_1", result); + + // the seconds the format actually asks for still scrub + Assert.Equal("DateTime_1", EngineRunner.Run("9", scrubbers)); + } + + [Fact] + public void SingleCharTrimmedFormatIsNotRejected() + { + // "H" is not a standard format specifier, so trimming used to throw + // "Invalid format: H" at registration + var scrubbers = DateMatchers.DateTimes("H.F", enUs); + + Assert.Equal(2, scrubbers.Length); + Assert.Equal("DateTime_1", EngineRunner.Run("9", scrubbers)); + } + + [Fact] + public void FractionOnlyFormatHasNoTrimmedScrubber() + { + // trimming leaves nothing to parse + var scrubbers = DateMatchers.DateTimes(".F", enUs); + + Assert.Single(scrubbers); + } +} diff --git a/src/Verify/Serialization/Scrubbers/DateMatchers.cs b/src/Verify/Serialization/Scrubbers/DateMatchers.cs index 7aa365ae3..bb9bd22f0 100644 --- a/src/Verify/Serialization/Scrubbers/DateMatchers.cs +++ b/src/Verify/Serialization/Scrubbers/DateMatchers.cs @@ -268,31 +268,46 @@ static bool StartsWithNumericToken(string format) static bool TryGetFormatWithUpperMillisecondsTrimmed(string format, [NotNullWhen(true)] out string? trimmedFormat) { + string trimmed; if (format.EndsWith(".FFFF", StringComparison.Ordinal)) { - trimmedFormat = format[..^5]; - return true; + trimmed = format[..^5]; } - - if (format.EndsWith(".FFF", StringComparison.Ordinal)) + else if (format.EndsWith(".FFF", StringComparison.Ordinal)) { - trimmedFormat = format[..^4]; - return true; + trimmed = format[..^4]; + } + else if (format.EndsWith(".FF", StringComparison.Ordinal)) + { + trimmed = format[..^3]; + } + else if (format.EndsWith(".F", StringComparison.Ordinal)) + { + trimmed = format[..^2]; + } + else + { + trimmedFormat = null; + return false; } - if (format.EndsWith(".FF", StringComparison.Ordinal)) + // Nothing is left to parse, so the untrimmed scrubber is the only one + if (trimmed.Length == 0) { - trimmedFormat = format[..^3]; - return true; + trimmedFormat = null; + return false; } - if (format.EndsWith(".F", StringComparison.Ordinal)) + // A one character format string is read as a standard format specifier, so "s.F" + // would trim to the culture's sortable pattern and "H.F" to nothing valid at all. + // `%` forces the character to be read as the custom specifier it was written as. + if (trimmed.Length == 1) { - trimmedFormat = format[..^2]; + trimmedFormat = $"%{trimmed}"; return true; } - trimmedFormat = null; - return false; + trimmedFormat = trimmed; + return true; } } diff --git a/src/todo.md b/src/todo.md index 03784f69b..96336c211 100644 --- a/src/todo.md +++ b/src/todo.md @@ -38,7 +38,7 @@ All six resolved 2026-08-16 (five fixed here; the inline item resolved as by-des - [ ] **`#` in parameter values collides with the indexed-target namespace.** `Verify/Naming/MatchingFileFinder.cs:9,20` — `indexedPattern: "{prefix}#"` matches by prefix, and `#` is not sanitized. Cases `"x"` and `"x#1"` on one method: running `"x"` deletes `C.M_p=x#1.received.txt` and sweeps `C.M_p=x#1.verified.txt` into the stale set (deleted under AutoVerify). -- [ ] **Trimmed fraction format collapses into a standard format specifier.** +- [x] **Trimmed fraction format collapses into a standard format specifier.** `Verify/Serialization/Scrubbers/DateMatchers.cs:269-297` (consumed at 118-127) — `ScrubInlineDateTimes("s.F")` builds a secondary scrubber for `"s"`; length-1 formats are standard specifiers, so it scrubs every full sortable date-time in the output. `"H.F"` trims to `"H"` and throws `Invalid format: H` at registration despite passing up-front validation. - [ ] **`MemberConverter` has no exact-type precedence.**