Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions Darling/Darling.Tests/PgSettingRedactorTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -518,4 +518,39 @@ public void ForcedTimeout_ThrowingCallback_StillReturnsMaskAndDoesNotThrow()
PgSettingRedactor.MatchTimeoutForTest = null;
}
}

/// <summary>
/// #4348: every <c>Compiled</c> pattern is warmed at type initialisation, before any real
/// <see cref="PgSettingRedactor.Redact"/> 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.
/// </summary>
[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);
}

/// <summary>
/// #4348: warmup must reach each pattern's MATCH step, not only its scan (<c>TryFindNextPossibleStartingPosition</c>
/// finding a candidate but <c>TryMatchAtCurrentPosition</c> never running). This pins that the warmup
/// sample every <see cref="PgSettingRedactor.TimeBoundPattern"/> is warmed with actually produces at
/// least one match for EVERY pattern in the list, so the match step is exercised, not skipped.
/// </summary>
[Fact]
public void WarmupSample_MatchesEveryWarmedPattern()
{
Assert.NotEmpty(PgSettingRedactor.WarmedPatterns);

foreach (var pattern in PgSettingRedactor.WarmedPatterns)
{
Assert.True(pattern.WarmupSampleMatches());
}
}
}
96 changes: 95 additions & 1 deletion PerformanceMonitor.Collectors/PgSettingRedactor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ public static class PgSettingRedactor

private const string Mask = "********";

/// <summary>A sample that reaches every pattern's match step (#4348), used only to warm each compiled
/// pattern's JIT ahead of the first real <see cref="Redact"/> call. Holds placeholder values only.</summary>
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";

/// <summary>
/// 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
Expand Down Expand Up @@ -97,7 +102,7 @@ internal static TimeSpan? MatchTimeoutForTest
/// <summary>Wraps one lookaround-bearing pattern so it always runs under a match timeout, while still
/// letting a test force a much shorter one via <see cref="MatchTimeoutForTest"/> without rebuilding
/// every call on the production path.</summary>
private sealed class TimeBoundPattern
internal sealed class TimeBoundPattern
{
private readonly string _pattern;
private readonly RegexOptions _options;
Expand All @@ -114,6 +119,35 @@ public string Replace(string input, MatchEvaluator evaluator) =>
(MatchTimeoutForTest is TimeSpan overrideTimeout
? new Regex(_pattern, _options, overrideTimeout)
: _default).Replace(input, evaluator);

/// <summary>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
/// <see cref="RegexOptions.Compiled"/> pattern's IL is JITted on its first invocation, and on a busy
/// CI runner that JIT cost alone can exceed the 100&#160;ms <see cref="MatchTimeout"/>, 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
/// <see cref="Redact"/> 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.</summary>
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
{
}
}

/// <summary>Runs <see cref="Warmup"/>'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.</summary>
internal bool WarmupSampleMatches() => _default.IsMatch(WarmupSample);
}

/// <summary>Names whose value is masked in full when the setting is extension-scoped (#4348), or when a
Expand Down Expand Up @@ -277,6 +311,66 @@ public string Replace(string input, MatchEvaluator evaluator) =>
"pwd",
};

/// <summary>Set once, before any real <see cref="Redact"/> call can return (#4348): the static
/// constructor below warms every <see cref="Compiled"/> pattern's first-use JIT cost before it counts
/// against anyone's <see cref="MatchTimeout"/>. 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 <c>AssemblyLoadContext</c> just to
/// observe type-initialisation order.</summary>
internal static readonly bool WarmedUp;

/// <summary>Every <see cref="TimeBoundPattern"/> 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
/// <see cref="UriUserInfoPassword"/> if it is not already in it, so a pattern added here can't be
/// missed from one of the two.</summary>
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;
Expand Down
Loading