Skip to content

perf(engine): use SearchValues<char> for reporter filename sanitization#6090

Merged
thomhurst merged 2 commits into
mainfrom
perf/issue-6035-searchvalues-reporters
May 28, 2026
Merged

perf(engine): use SearchValues<char> for reporter filename sanitization#6090
thomhurst merged 2 commits into
mainfrom
perf/issue-6035-searchvalues-reporters

Conversation

@thomhurst
Copy link
Copy Markdown
Owner

Summary

Implements #6035. Replaces the allocating string.Concat(name.Split(Path.GetInvalidFileNameChars())) pattern used to sanitize the entry-assembly name in HtmlReporter and JUnitReporter with a shared PathValidator.SanitizeFileName helper.

  • net8.0+: single-pass SearchValues<char> scan into a stack (or heap, for names > 256 chars) buffer, with an allocation-free fast path that returns the original string unchanged when no invalid characters are present.
  • netstandard2.0: keeps the existing Split implementation as a fallback (guarded with #if NET8_0_OR_GREATER), so all TFMs compile.

Behavior is identical (invalid filename characters are removed). The two previously-duplicated call sites now share one helper.

Testing

dotnet build TUnit.Engine/TUnit.Engine.csproj -c Release succeeds across all four TFMs (netstandard2.0, net8.0, net9.0, net10.0). The GitVersion MSBuild task fails in the worktree due to git state, so the build was run with -p:DisableGitVersionTask=true; the failure is unrelated to these code changes.

Closes #6035

Replace `string.Concat(name.Split(Path.GetInvalidFileNameChars()))` in
HtmlReporter and JUnitReporter with a shared `PathValidator.SanitizeFileName`
helper. On net8.0+ it does a single-pass SearchValues<char> scan into a
stack/heap buffer with an allocation-free fast path when the name is already
clean; netstandard2.0 keeps the existing Split fallback.

Closes #6035
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

Clean implementation overall. The SearchValues<char> approach is a well-known .NET pattern for this kind of hot-path sanitization and the fast-path (return original string unchanged when no invalid chars exist) is the right call. A few things worth discussing:

Missing Unit Tests

PathValidator.SanitizeFileName is a pure function with multiple distinct code paths: fast path (no invalid chars), stack-allocated slow path (≤256 chars), and heap-allocated slow path (>256 chars). None of these are covered by new tests.

Given the method now consolidates a security-adjacent responsibility (stripping characters that could affect file paths), I'd suggest adding tests for at least:

  • A clean name → same reference returned (fast path)
  • A name with embedded / or \ → chars removed
  • An empty string
  • A 257-char name with an invalid char (exercises the heap-alloc branch)

Doc Comment Overstates Security Guarantee

/// Removes any characters that are invalid in a file name (e.g. path separators),
/// preventing path traversal via crafted assembly names.

This is copied from the JUnit reporter's inline comment but is misleading as a method contract. SanitizeFileName removes Path.GetInvalidFileNameChars(), which on Linux is only ['/', '\0'] — it does not strip . so a name like ..malicious.. passes through unchanged. Full path traversal prevention is ValidateAndNormalizePath's job. The doc comment should say "strips characters that are invalid in a file name component" without the path-traversal framing, to avoid implying stronger guarantees.

ArrayPool for Large Names (Minor)

The heap-allocation branch (name.Length > 256) allocates a new char[name.Length] array that immediately becomes garbage. For assembly names this will never fire in practice, but for correctness/completeness the pattern ArrayPool<char>.Shared.Rent + try/finally return would eliminate that allocation entirely. That said, the current code is simpler and this case is effectively unreachable, so I wouldn't block on it.

Everything Else Looks Good

  • The SearchValues<char> static field is correctly private static readonly and thread-safe.
  • The ternary stackalloc char[name.Length] : new char[name.Length] correctly produces Span<char> in both branches — valid C# since the array is implicitly convertible.
  • The #if NET8_0_OR_GREATER / netstandard2.0 fallback structure is the right approach for multi-TFM support.
  • Consolidating the two previously-duplicated call sites into one helper is a worthwhile deduplication.

Main ask before merging: add unit tests and tighten the doc comment's security claim.

@codacy-production
Copy link
Copy Markdown

codacy-production Bot commented May 28, 2026

Up to standards ✅

🟢 Issues 0 issues

Results:
0 new issues

View in Codacy

🟢 Metrics 7 complexity

Metric Results
Complexity 7

View in Codacy

NEW Get contextual insights on your PRs based on Codacy's metrics, along with PR and Jira context, without leaving GitHub. Enable AI reviewer
TIP This summary will be updated as you push new changes.

@
test(engine): cover PathValidator.SanitizeFileName; correct doc comment

Adds unit tests for the fast path (clean name -> same reference), empty
string, separator/invalid-char stripping, and the >256-char heap branch.
Rewords the doc comment to drop the overstated path-traversal guarantee
(SanitizeFileName only strips Path.GetInvalidFileNameChars; full traversal
protection is ValidateAndNormalizePath's job).
@
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — perf/issue-6035-searchvalues-reporters

Overall this is a solid, well-motivated PR: the SearchValues<char> fast path is correct, the buffer sizing is right (no off-by-one), the #else fallback preserves the original behavior exactly, and the callers are unchanged in semantics. The implementation itself is fine.

Three things worth addressing before merge:


1. Misleading docstring: "allocation-free" claim is false on netstandard2.0

File: TUnit.Engine/Helpers/PathValidator.cs, line 20

"allocation-free when the name is already clean"

This is only true for the #if NET8_0_OR_GREATER branch. The #else fallback always calls string.Concat(name.Split(Path.GetInvalidFileNameChars())), which allocates a char[] from GetInvalidFileNameChars(), a string[] from Split, and a new string from Concat — even when the name has no invalid characters.

TUnit.Engine ships a netstandard2.0 TFM, so consumers on that target will see the opposite of what the doc promises. The doc should either scope the claim (NET8+: allocation-free fast path; older TFMs: O(n) allocation) or the #else path should add its own fast-path check before calling Split.


2. The netstandard2.0 #else path has zero test coverage

File: TUnit.UnitTests/PathValidatorTests.cs

TestProject.props defaults all test projects to net10.0 only. The five new tests therefore exclusively exercise the NET8_0_OR_GREATER branch. A regression in string.Concat(name.Split(...)) would ship unnoticed.

The two reference-identity tests (SanitizeFileName_CleanName_ReturnsSameReference and SanitizeFileName_EmptyString_ReturnsSameReference) would also fail if the project were ever multi-targeted to include a pre-NET8 TFM, because string.Concat(new[]{"MyAssembly.Tests"}) allocates a new string object — IsSameReferenceAs would throw. The tests lock in an implementation detail that only holds on one branch.

Suggested fix: Either add a test that explicitly covers the #else branch's contract (output equality, not reference identity), or replace IsSameReferenceAs with a value-equality assertion that holds on all TFMs:

// Instead of: await Assert.That(result).IsSameReferenceAs(name);
await Assert.That(result).IsEqualTo(name); // correct on all TFMs

3. SanitizeFileName_StripsPathSeparators assertion is too weak

File: TUnit.UnitTests/PathValidatorTests.cs, line 33

var result = PathValidator.SanitizeFileName("foo/bar");
await Assert.That(result).DoesNotContain("/");

A broken implementation that returns "" (or "fo") would satisfy DoesNotContain("/"). The test should verify the full output:

await Assert.That(result).IsEqualTo("foobar");

Minor / informational

  • Null input divergence (latent): SanitizeFileName(null) silently returns null on NET8+ (empty span hits the fast path, returning the original name which is null) but throws NullReferenceException on netstandard2.0. Both current callers guard with ?? "TestResults", so this can't fire today — but a future caller could hit different behaviour on different TFMs with no indication from the method signature. A single ArgumentNullException.ThrowIfNull(name) at the top of the method would make the contract explicit and consistent.

  • The stackalloc char[name.Length] at the 256-char threshold is reasonable (512 bytes), and AOT/SearchValues compatibility is correct — no issues there.

@thomhurst thomhurst merged commit cc29888 into main May 28, 2026
14 checks passed
@thomhurst thomhurst deleted the perf/issue-6035-searchvalues-reporters branch May 28, 2026 19:32
github-actions Bot pushed a commit to IntelliTect/CodingGuidelines that referenced this pull request Jun 2, 2026
Updated [TUnit.Core](https://github.com/thomhurst/TUnit) from 1.45.29 to
1.48.6.

<details>
<summary>Release notes</summary>

_Sourced from [TUnit.Core's
releases](https://github.com/thomhurst/TUnit/releases)._

## 1.48.6

<!-- Release notes generated using configuration in .github/release.yml
at v1.48.6 -->

## What's Changed
### Other Changes
* fix(sourcegen): fully-qualify Linq calls in params array binding
(#​6140) by @​thomhurst in thomhurst/TUnit#6141
### Dependencies
* chore(deps): update tunit to 1.48.0 by @​thomhurst in
thomhurst/TUnit#6135
* chore(deps): update dependency polyfill to 10.7.1 by @​thomhurst in
thomhurst/TUnit#6137
* chore(deps): update dependency polyfill to 10.7.1 by @​thomhurst in
thomhurst/TUnit#6138
* chore(deps): update verify to 31.19.0 by @​thomhurst in
thomhurst/TUnit#6139


**Full Changelog**:
thomhurst/TUnit@v1.48.0...v1.48.6

## 1.48.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.48.0 -->

## What's Changed
### Other Changes
* feat(html-report): baked-in C# syntax highlighting on Source tab by
@​slang25 in thomhurst/TUnit#6132
* feat(analyzers): suppress VSTHRD200 on test and hook methods by
@​thomhurst in thomhurst/TUnit#6123
* fix(source-gen): correct source location for cross-project inherited
tests by @​slang25 in thomhurst/TUnit#6133
* feat(assertions): add WasCalled to tunit mocks assertions by
@​robertcoltheart in thomhurst/TUnit#6126
* feat(arguments): bind array values to a single array test parameter by
@​thomhurst in thomhurst/TUnit#6122
* fix: populate retry/flaky attempt history in HTML report (#​6119) by
@​thomhurst in thomhurst/TUnit#6124
### Dependencies
* chore(deps): update tunit to 1.47.0 by @​thomhurst in
thomhurst/TUnit#6115
* chore(deps): update dependency
microsoft.visualstudio.threading.analyzers to 17.14.15 by @​thomhurst in
thomhurst/TUnit#6134


**Full Changelog**:
thomhurst/TUnit@v1.47.0...v1.48.0

## 1.47.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.47.0 -->

## What's Changed
### Other Changes
* perf(engine): hoist GetParameters and dict-dedup AfterTestDiscovery
hooks by @​thomhurst in thomhurst/TUnit#6062
* perf(engine): hoist GetParameters and drop LINQ in reflection
discovery by @​thomhurst in thomhurst/TUnit#6063
* perf(engine): cache treenode filter path on TestMetadata by
@​thomhurst in thomhurst/TUnit#6064
* perf: use is T pattern in ReflectionExtensions.HasAttribute fallback
(#​6060) by @​thomhurst in thomhurst/TUnit#6066
* perf: replace OrderBy().ToArray() with Array.Sort in
ConstraintKeyScheduler by @​thomhurst in
thomhurst/TUnit#6067
* perf: pool HashSet in WaitingTestIndex.GetCandidatesForReleasedKeys by
@​thomhurst in thomhurst/TUnit#6069
* perf: collapse OfType chains in JUnitXmlWriter (#​6052) by @​thomhurst
in thomhurst/TUnit#6070
* perf(engine): avoid closure allocation in
AfterHookPairTracker.GetOrCreateAfterAssemblyTask (#​6041) by
@​thomhurst in thomhurst/TUnit#6071
* perf: avoid closure allocation in
BeforeHookTaskCache.GetOrCreateBeforeAssemblyTask (#​6040) by
@​thomhurst in thomhurst/TUnit#6073
* perf: use TryAdd in TestDependencyResolver dependency dedupe by
@​thomhurst in thomhurst/TUnit#6068
* perf: replace LINQ Any with foreach in TestGenericTypeResolver
(#​6044) by @​thomhurst in thomhurst/TUnit#6072
* perf: avoid Cast<object>().FirstOrDefault() iterator alloc in
CastHelper (#​6029) by @​thomhurst in
thomhurst/TUnit#6074
* perf(engine): avoid string round-trip when building nested type names
(#​6049) by @​thomhurst in thomhurst/TUnit#6075
* perf(engine): replace Select+ToArray with manual Type[] build (#​6043)
by @​thomhurst in thomhurst/TUnit#6076
* perf(core): replace OfType().FirstOrDefault()/.Any() with foreach in
ClassConstructorHelper by @​thomhurst in
thomhurst/TUnit#6078
* perf(engine): avoid FirstOrDefault iterator alloc in
TestGenericTypeResolver by @​thomhurst in
thomhurst/TUnit#6079
* perf(engine): use SearchValues<char> for reporter filename
sanitization by @​thomhurst in
thomhurst/TUnit#6090
* perf: dedupe TestDataFormatter.FormatArguments with pooled
StringBuilder by @​thomhurst in
thomhurst/TUnit#6088
* perf(engine): use MemoryExtensions.Split for path parsing in
MetadataFilterMatcher by @​thomhurst in
thomhurst/TUnit#6085
* perf(engine): use CollectionsMarshal.GetValueRefOrAddDefault for
dictionary index builds by @​thomhurst in
thomhurst/TUnit#6086
* perf(engine): replace LINQ Where closure with inline filter in
MetadataDependencyExpander BFS by @​thomhurst in
thomhurst/TUnit#6084
* perf(engine): pool StringBuilder in DisplayNameBuilder.FormatArguments
by @​thomhurst in thomhurst/TUnit#6082
* Preserve specialized chaining after null assertions by @​thomhurst in
thomhurst/TUnit#6008
* perf: use EnumerateLines for line splitting in HtmlReportGenerator by
@​thomhurst in thomhurst/TUnit#6089
* perf: collapse Replace chain in TestNameFormatter.BuildTestId by
@​thomhurst in thomhurst/TUnit#6083
* perf: use OrdinalIgnoreCase Contains in HtmlReportGenerator span
mapping by @​thomhurst in thomhurst/TUnit#6093
* perf(assertions): avoid eager interpolated-string alloc in assertion
source ctors by @​thomhurst in
thomhurst/TUnit#6091
* perf: optimize TestNameFormatter argument and bool formatting by
@​thomhurst in thomhurst/TUnit#6095
* perf: use FrozenSet/FrozenDictionary for read-only static lookups by
@​thomhurst in thomhurst/TUnit#6099
* perf: avoid GetCustomAttributes() + LINQ chain for per-property
attribute scans by @​thomhurst in
thomhurst/TUnit#6098
* perf(engine): replace magic-string RequiredAttribute match with type
check in ConstructorHelper by @​thomhurst in
thomhurst/TUnit#6087
* perf(core): replace Select+Func factory chain in DataSourceHelpers by
@​thomhurst in thomhurst/TUnit#6081
* perf: replace LINQ dependency extraction with manual loop by
@​thomhurst in thomhurst/TUnit#6096
* perf(core): avoid string[] alloc in ArgumentFormatter.FormatArguments
by @​thomhurst in thomhurst/TUnit#6080
* perf: use [GeneratedRegex] in MetadataFilterMatcher by @​thomhurst in
thomhurst/TUnit#6094
* perf: dedupe GetSimpleTypeName into shared TypeNameFormatter by
@​thomhurst in thomhurst/TUnit#6097
* fix: remove GitVersion MSBuild task, pin local builds to 99.99.99
(#​6077) by @​thomhurst in thomhurst/TUnit#6101
* HTML Report: source link + code snippet on Source tab (#​5993) by
@​thomhurst in thomhurst/TUnit#6100
* perf(sourcegen): Single-pass attribute classification by @​thomhurst
in thomhurst/TUnit#6111
* perf(core): eliminate per-test allocations in TestDetails/HookMethod
by @​thomhurst in thomhurst/TUnit#6109
* perf: hoist char[] alloc in FsCheckPropertyTestExecutor to static
SearchValues by @​thomhurst in
thomhurst/TUnit#6108
* perf(core): de-LINQ data-source expansion by @​thomhurst in
thomhurst/TUnit#6110
* perf: avoid LINQ chains in TestDependency equality and
MethodDataSourceAttribute method matching by @​thomhurst in
thomhurst/TUnit#6092
* perf(engine): reduce allocations in reflection-mode
discovery/execution by @​thomhurst in
thomhurst/TUnit#6113
* perf(assertions): allocation-free passing path (TUnit.Assertions) by
@​thomhurst in thomhurst/TUnit#6112
### Dependencies
 ... (truncated)

## 1.46.0

<!-- Release notes generated using configuration in .github/release.yml
at v1.46.0 -->

## What's Changed
### Other Changes
* docs: add Rider VSTest conflict troubleshooting by @​smolchanovsky in
thomhurst/TUnit#5989
* Populate generated test metadata with full source spans by @​Copilot
in thomhurst/TUnit#5991
* Add devcontainer configuration by @​Copilot in
thomhurst/TUnit#5995
* fix: treenode filter pre-filter rejects parenthesised segments
(#​6026) by @​thomhurst in thomhurst/TUnit#6027
* fix(engine): isolate per-session state under MTP server-mode
concurrency (#​6001) by @​thomhurst in
thomhurst/TUnit#6025
### Dependencies
* chore(deps): update dependency stackexchange.redis to 2.13.10 by
@​thomhurst in thomhurst/TUnit#5985
* chore(deps): update tunit to 1.45.29 by @​thomhurst in
thomhurst/TUnit#5986
* chore(deps): update dependency mockolate to 3.2.1 by @​thomhurst in
thomhurst/TUnit#5987
* chore(deps): update dependency microsoft.playwright to 1.60.0 by
@​thomhurst in thomhurst/TUnit#5988
* chore(deps): update dependency messagepack to 3.1.6 by @​thomhurst in
thomhurst/TUnit#5992
* chore(deps): update dependency polyfill to 10.7.0 by @​thomhurst in
thomhurst/TUnit#5998
* chore(deps): update dependency polyfill to 10.7.0 by @​thomhurst in
thomhurst/TUnit#5997
* chore(deps): update verify to 31.17.0 by @​thomhurst in
thomhurst/TUnit#6000
* chore(deps): update verify to 31.18.0 by @​thomhurst in
thomhurst/TUnit#6013
* chore(deps): update dependency microsoft.net.test.sdk to 18.6.0 by
@​thomhurst in thomhurst/TUnit#6016
* chore(deps): update dependency dompurify to v3.4.6 by @​thomhurst in
thomhurst/TUnit#6015
* chore(deps): update dependency dompurify to v3.4.7 by @​thomhurst in
thomhurst/TUnit#6019
* chore(deps): update dependency npgsql to 10.0.3 by @​thomhurst in
thomhurst/TUnit#6020
* chore(deps): update dependency stackexchange.redis to 2.13.17 by
@​thomhurst in thomhurst/TUnit#6021
* chore(deps): update dependency npgsql.entityframeworkcore.postgresql
to 10.0.2 by @​thomhurst in thomhurst/TUnit#6022

## New Contributors
* @​smolchanovsky made their first contribution in
thomhurst/TUnit#5989

**Full Changelog**:
thomhurst/TUnit@v1.45.29...v1.46.0

Commits viewable in [compare
view](thomhurst/TUnit@v1.45.29...v1.48.6).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=TUnit.Core&package-manager=nuget&previous-version=1.45.29&new-version=1.48.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
This was referenced Jun 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: use SearchValues<char> for invalid-filename filtering in reporters

1 participant