From 61190d66bd6505baebf31e47aa3d33ccb7745816 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 19:36:44 +0100 Subject: [PATCH 1/2] perf(engine): use SearchValues for reporter filename sanitization 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 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 --- TUnit.Engine/Helpers/PathValidator.cs | 50 +++++++++++++++++++++ TUnit.Engine/Reporters/Html/HtmlReporter.cs | 3 +- TUnit.Engine/Reporters/JUnitReporter.cs | 2 +- 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/TUnit.Engine/Helpers/PathValidator.cs b/TUnit.Engine/Helpers/PathValidator.cs index cb5e5c6457..06ed59849a 100644 --- a/TUnit.Engine/Helpers/PathValidator.cs +++ b/TUnit.Engine/Helpers/PathValidator.cs @@ -1,7 +1,57 @@ +#if NET8_0_OR_GREATER +using System.Buffers; +#endif + namespace TUnit.Engine.Helpers; internal static class PathValidator { +#if NET8_0_OR_GREATER + private static readonly SearchValues _invalidFileNameChars = + SearchValues.Create(Path.GetInvalidFileNameChars()); +#endif + + /// + /// Removes any characters that are invalid in a file name (e.g. path separators), + /// preventing path traversal via crafted assembly names. Equivalent to + /// string.Concat(name.Split(Path.GetInvalidFileNameChars())) but allocation-free + /// when the name is already clean. + /// + internal static string SanitizeFileName(string name) + { +#if NET8_0_OR_GREATER + var span = name.AsSpan(); + var firstInvalid = span.IndexOfAny(_invalidFileNameChars); + + // Fast path: nothing to strip, return the original string unchanged. + if (firstInvalid < 0) + { + return name; + } + + // Worst case the result is the same length as the input (no chars removed + // after the first), so a single stack/heap buffer of that size suffices. + var buffer = name.Length <= 256 ? stackalloc char[name.Length] : new char[name.Length]; + + // Everything before the first invalid char is known-good. + span.Slice(0, firstInvalid).CopyTo(buffer); + var written = firstInvalid; + + for (var i = firstInvalid; i < span.Length; i++) + { + var c = span[i]; + if (!_invalidFileNameChars.Contains(c)) + { + buffer[written++] = c; + } + } + + return new string(buffer.Slice(0, written)); +#else + return string.Concat(name.Split(Path.GetInvalidFileNameChars())); +#endif + } + /// /// Validates and normalizes a file path to prevent path traversal attacks. /// Returns the normalized full path if valid, or throws an if the path is unsafe. diff --git a/TUnit.Engine/Reporters/Html/HtmlReporter.cs b/TUnit.Engine/Reporters/Html/HtmlReporter.cs index 88e8cca37c..edbe1c138e 100644 --- a/TUnit.Engine/Reporters/Html/HtmlReporter.cs +++ b/TUnit.Engine/Reporters/Html/HtmlReporter.cs @@ -16,6 +16,7 @@ using TUnit.Engine.Constants; using TUnit.Engine.Exceptions; using TUnit.Engine.Framework; +using TUnit.Engine.Helpers; #pragma warning disable TPEXP @@ -647,7 +648,7 @@ private static (string Status, ReportExceptionData? Exception, string? SkipReaso private string GetDefaultOutputPath() { var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; - var sanitizedName = string.Concat(assemblyName.Split(Path.GetInvalidFileNameChars())); + var sanitizedName = PathValidator.SanitizeFileName(assemblyName); var os = GetShortOsName(); var tfm = GetShortFrameworkName(); return Path.GetFullPath(Path.Combine(_resultsDirectory, $"{sanitizedName}-{os}-{tfm}-report.html")); diff --git a/TUnit.Engine/Reporters/JUnitReporter.cs b/TUnit.Engine/Reporters/JUnitReporter.cs index 3a40aa673d..eb194e14a8 100644 --- a/TUnit.Engine/Reporters/JUnitReporter.cs +++ b/TUnit.Engine/Reporters/JUnitReporter.cs @@ -121,7 +121,7 @@ private string GetDefaultOutputPath() var assemblyName = Assembly.GetEntryAssembly()?.GetName().Name ?? "TestResults"; // Sanitize assembly name to remove any characters that could be used for path traversal - var sanitizedName = string.Concat(assemblyName.Split(Path.GetInvalidFileNameChars())); + var sanitizedName = PathValidator.SanitizeFileName(assemblyName); return Path.GetFullPath(Path.Combine(_resultsDirectory, $"{sanitizedName}-junit.xml")); } From c53a581b50383a3fbc9e6def5fc72e6734cc1ba0 Mon Sep 17 00:00:00 2001 From: Tom Longhurst <30480171+thomhurst@users.noreply.github.com> Date: Thu, 28 May 2026 19:55:00 +0100 Subject: [PATCH 2/2] @ 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). @ --- TUnit.Engine/Helpers/PathValidator.cs | 10 ++-- TUnit.UnitTests/PathValidatorTests.cs | 74 +++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 TUnit.UnitTests/PathValidatorTests.cs diff --git a/TUnit.Engine/Helpers/PathValidator.cs b/TUnit.Engine/Helpers/PathValidator.cs index 06ed59849a..ac6ca09946 100644 --- a/TUnit.Engine/Helpers/PathValidator.cs +++ b/TUnit.Engine/Helpers/PathValidator.cs @@ -12,10 +12,12 @@ internal static class PathValidator #endif /// - /// Removes any characters that are invalid in a file name (e.g. path separators), - /// preventing path traversal via crafted assembly names. Equivalent to - /// string.Concat(name.Split(Path.GetInvalidFileNameChars())) but allocation-free - /// when the name is already clean. + /// Strips characters that are invalid in a file name component (those returned by + /// ). This is not full path-traversal protection + /// — e.g. on Linux only / and \0 are invalid, so a value like ..foo + /// passes through unchanged; use for that. + /// Equivalent to string.Concat(name.Split(Path.GetInvalidFileNameChars())) but + /// allocation-free when the name is already clean. /// internal static string SanitizeFileName(string name) { diff --git a/TUnit.UnitTests/PathValidatorTests.cs b/TUnit.UnitTests/PathValidatorTests.cs new file mode 100644 index 0000000000..40c143d19c --- /dev/null +++ b/TUnit.UnitTests/PathValidatorTests.cs @@ -0,0 +1,74 @@ +using TUnit.Assertions.Extensions; +using TUnit.Engine.Helpers; + +namespace TUnit.UnitTests; + +public class PathValidatorTests +{ + [Test] + public async Task SanitizeFileName_CleanName_ReturnsSameReference() + { + var name = "MyAssembly.Tests"; + + var result = PathValidator.SanitizeFileName(name); + + // Fast path: no invalid chars, the original instance is returned unchanged. + await Assert.That(result).IsSameReferenceAs(name); + } + + [Test] + public async Task SanitizeFileName_EmptyString_ReturnsSameReference() + { + var name = string.Empty; + + var result = PathValidator.SanitizeFileName(name); + + await Assert.That(result).IsSameReferenceAs(name); + } + + [Test] + public async Task SanitizeFileName_StripsPathSeparators() + { + // '/' is invalid on every platform; '\' is invalid on Windows. + var result = PathValidator.SanitizeFileName("foo/bar"); + + await Assert.That(result).DoesNotContain("/"); + } + + [Test] + public async Task SanitizeFileName_StripsInvalidChars() + { + var invalid = Path.GetInvalidFileNameChars(); + + // Skip platforms with no invalid chars (none in practice, but keep the test honest). + if (invalid.Length == 0) + { + return; + } + + var name = $"a{invalid[0]}b"; + + var result = PathValidator.SanitizeFileName(name); + + await Assert.That(result).IsEqualTo("ab"); + } + + [Test] + public async Task SanitizeFileName_LongNameWithInvalidChar_ExercisesHeapBranch() + { + var invalid = Path.GetInvalidFileNameChars(); + + if (invalid.Length == 0) + { + return; + } + + // > 256 chars forces the heap-allocated (non-stackalloc) slow path. + var prefix = new string('a', 300); + var name = prefix + invalid[0]; + + var result = PathValidator.SanitizeFileName(name); + + await Assert.That(result).IsEqualTo(prefix); + } +}