perf(engine): use SearchValues<char> for reporter filename sanitization#6090
Conversation
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
There was a problem hiding this comment.
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 correctlyprivate static readonlyand thread-safe. - The ternary
stackalloc char[name.Length] : new char[name.Length]correctly producesSpan<char>in both branches — valid C# since the array is implicitly convertible. - The
#if NET8_0_OR_GREATER/netstandard2.0fallback 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.
Up to standards ✅🟢 Issues
|
| Metric | Results |
|---|---|
| Complexity | 7 |
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). @
There was a problem hiding this comment.
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 TFMs3. 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 returnsnullon NET8+ (empty span hits the fast path, returning the originalnamewhich isnull) but throwsNullReferenceExceptiononnetstandard2.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 singleArgumentNullException.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/SearchValuescompatibility is correct — no issues there.
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> [](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>
Summary
Implements #6035. Replaces the allocating
string.Concat(name.Split(Path.GetInvalidFileNameChars()))pattern used to sanitize the entry-assembly name inHtmlReporterandJUnitReporterwith a sharedPathValidator.SanitizeFileNamehelper.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.Splitimplementation 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 Releasesucceeds 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