diff --git a/Darling/Darling.Tests/PgSettingRedactorTests.cs b/Darling/Darling.Tests/PgSettingRedactorTests.cs index 6a9849cdb3..71356034cd 100644 --- a/Darling/Darling.Tests/PgSettingRedactorTests.cs +++ b/Darling/Darling.Tests/PgSettingRedactorTests.cs @@ -518,4 +518,39 @@ public void ForcedTimeout_ThrowingCallback_StillReturnsMaskAndDoesNotThrow() PgSettingRedactor.MatchTimeoutForTest = null; } } + + /// + /// #4348: every Compiled pattern is warmed at type initialisation, before any real + /// call can return, so the first real call never pays a + /// first-use JIT cost against the 100ms match timeout. This pins that the warmup ran (rather than + /// re-deriving that fact from timing, which would be flaky by construction on a busy runner) and that a + /// short, well-formed value is masked in PART, not masked whole, the way a spurious timeout would + /// produce. + /// + [Fact] + public void Warmup_RanBeforeFirstRealCall_AndShortValueIsNotMaskedWhole() + { + Assert.True(PgSettingRedactor.WarmedUp); + + var result = PgSettingRedactor.Redact("archive_command", "PGPASSWORD=hunter2 psql -c 'select 1'"); + + Assert.Equal("PGPASSWORD=******** psql -c 'select 1'", result); + } + + /// + /// #4348: warmup must reach each pattern's MATCH step, not only its scan (TryFindNextPossibleStartingPosition + /// finding a candidate but TryMatchAtCurrentPosition never running). This pins that the warmup + /// sample every is warmed with actually produces at + /// least one match for EVERY pattern in the list, so the match step is exercised, not skipped. + /// + [Fact] + public void WarmupSample_MatchesEveryWarmedPattern() + { + Assert.NotEmpty(PgSettingRedactor.WarmedPatterns); + + foreach (var pattern in PgSettingRedactor.WarmedPatterns) + { + Assert.True(pattern.WarmupSampleMatches()); + } + } } diff --git a/PerformanceMonitor.Collectors/PgSettingRedactor.cs b/PerformanceMonitor.Collectors/PgSettingRedactor.cs index bfcabc086c..ea58d7551d 100644 --- a/PerformanceMonitor.Collectors/PgSettingRedactor.cs +++ b/PerformanceMonitor.Collectors/PgSettingRedactor.cs @@ -66,6 +66,11 @@ public static class PgSettingRedactor private const string Mask = "********"; + /// A sample that reaches every pattern's match step (#4348), used only to warm each compiled + /// pattern's JIT ahead of the first real call. Holds placeholder values only. + private const string WarmupSample = + "password=x sslpassword=y postgresql://u:p@h/?password=z&sig=s&X-Amz-Signature=t \"password\" = \"q\" --password w sshpass -p v curl -u a:b"; + /// /// A per-regex match-time bound (#4348): a pathological value (a long run with no separator, feeding one /// of the lookaround-heavy patterns below) could otherwise pin the engine backtracking well past the @@ -97,7 +102,7 @@ internal static TimeSpan? MatchTimeoutForTest /// Wraps one lookaround-bearing pattern so it always runs under a match timeout, while still /// letting a test force a much shorter one via without rebuilding /// every call on the production path. - private sealed class TimeBoundPattern + internal sealed class TimeBoundPattern { private readonly string _pattern; private readonly RegexOptions _options; @@ -114,6 +119,35 @@ public string Replace(string input, MatchEvaluator evaluator) => (MatchTimeoutForTest is TimeSpan overrideTimeout ? new Regex(_pattern, _options, overrideTimeout) : _default).Replace(input, evaluator); + + /// Runs the DEFAULT (production-timeout) instance once on a sample that reaches every + /// pattern's match step, outside any timed accounting the caller does (#4348): a + /// pattern's IL is JITted on its first invocation, and on a busy + /// CI runner that JIT cost alone can exceed the 100 ms , turning the + /// first real call on a short, well-formed value into a spurious whole-value mask. Warming here, at + /// type initialisation, pays that cost once, before the type is usable at all, so the first real + /// call never has to. A timeout during warmup (the JIT itself, on an especially + /// slow runner, taking longer than the timeout) is swallowed — warmup exists to pre-pay JIT cost, + /// not to prove the pattern is fast. + public void Warmup() + { + // Catch-all, not only the timeout (#4348): ANY exception here, uncaught, would fail the static + // constructor and leave the type permanently unusable (every later call throws + // TypeInitializationException) — warmup exists to pre-pay a cost, never to gate whether the type + // works at all. + try + { + _default.IsMatch(WarmupSample); + } + catch + { + } + } + + /// Runs 's sample through this pattern and reports whether it produced + /// at least one match (#4348) — used only by the test that pins that warmup reaches every pattern's + /// match step, not only its scan. + internal bool WarmupSampleMatches() => _default.IsMatch(WarmupSample); } /// Names whose value is masked in full when the setting is extension-scoped (#4348), or when a @@ -277,6 +311,66 @@ public string Replace(string input, MatchEvaluator evaluator) => "pwd", }; + /// Set once, before any real call can return (#4348): the static + /// constructor below warms every pattern's first-use JIT cost before it counts + /// against anyone's . Exposed only so a test can pin that warmup ran ahead of + /// the first real call, without the test having to spin up a fresh AssemblyLoadContext just to + /// observe type-initialisation order. + internal static readonly bool WarmedUp; + + /// Every this type warms at type initialisation (#4348) — + /// exposed only so a test can pin that the warmup sample actually reaches each one's match step, not + /// only its scan. The static constructor below drives its warm-up from THIS list, plus + /// if it is not already in it, so a pattern added here can't be + /// missed from one of the two. + internal static readonly TimeBoundPattern[] WarmedPatterns = + { + LibpqPasswordKeyword, + UriQueryPassword, + AssignmentSecretName, + OptionSecretSpaced, + UriQueryKeyAnyEncoding, + UriQuerySignature, + QuotedSpacedAssignment, + CurlUserColon, + SshpassOption, + }; + + static PgSettingRedactor() + { + // Catch-all around every warm-up step, not only the timeout (#4348): ANY exception escaping the + // static constructor fails type initialisation, and every later Redact call would then throw + // TypeInitializationException forever — warmup exists to pre-pay JIT cost, never to gate whether the + // type works at all. + foreach (var pattern in WarmedPatterns) + { + pattern.Warmup(); + } + + try + { + UriUserInfoPassword.IsMatch(WarmupSample); + } + catch + { + } + + // Warms the evaluator lambdas too, not only the patterns' match step (#4348): the match-timeout + // clock spans a whole Regex.Replace call, including its MatchEvaluator, so a full Redact call here + // JITs those delegates before any real call has to. Safe to call from the static constructor because + // every field Redact reads (the patterns above, Mask, QueryKeySecretMarkers) is field-initialised, + // and field initialisers run before this constructor body. + try + { + Redact(null, WarmupSample); + } + catch + { + } + + WarmedUp = true; + } + private static bool IsQueryKeySecret(string rawKey) { string decoded;