Add artifact naming service with template support for consistent naming across extensions#7856
Add artifact naming service with template support for consistent naming across extensions#7856Evangelink wants to merge 118 commits into
Conversation
Co-authored-by: nohwnd <5735905+nohwnd@users.noreply.github.com>
Co-authored-by: nohwnd <5735905+nohwnd@users.noreply.github.com>
…ssing imports Co-authored-by: nohwnd <5735905+nohwnd@users.noreply.github.com>
Co-authored-by: nohwnd <5735905+nohwnd@users.noreply.github.com>
…ames Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com>
# Conflicts: # src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs # src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs
…ct-naming-service
- Remove <root> placeholder (fragile directory traversal) - Fix OperatingSystem.IsWindows/IsLinux/IsMacOS for netstandard2.0 compat (use RuntimeInformation) - Fix KeyValuePair deconstruction for netstandard2.0 compat - Fix Guard.NotNullOrEmpty (non-existent API) - Fix legacy %p pattern handling (move to HangDump caller, not the service) - Fix time format to use hyphens instead of colons (filesystem safe) - Remove unnecessary using directives - Simplify GetOperatingSystemName with ternary expression - Simplify TFM detection (remove hard-coded version map) - Fix tests: use Assert.ThrowsExactly, match constructor signature, update assertions
There was a problem hiding this comment.
Pull request overview
This PR introduces an internal IArtifactNamingService to centralize artifact filename/path templating across Microsoft.Testing.Platform extensions, and migrates HangDump to use it (while keeping legacy %p support).
Changes:
- Added
IArtifactNamingService+ArtifactNamingServiceimplementation with placeholder-based template resolution. - Registered the service in
TestHostBuilderand exposed it viaIServiceProviderextension method. - Updated HangDump to use the service and added/updated unit + integration tests plus documentation.
Show a summary per file
| File | Description |
|---|---|
| test/UnitTests/Microsoft.Testing.Platform.UnitTests/Services/ArtifactNamingServiceTests.cs | Adds unit tests covering placeholder replacement, casing, unknown placeholders, and error cases. |
| test/IntegrationTests/Microsoft.Testing.Platform.Acceptance.IntegrationTests/HangDumpTests.cs | Adds integration coverage for HangDump template-based filenames. |
| src/Platform/Microsoft.Testing.Platform/Services/ServiceProviderExtensions.cs | Adds GetArtifactNamingService() accessor. |
| src/Platform/Microsoft.Testing.Platform/Services/IArtifactNamingService.cs | Introduces the internal artifact naming service contract. |
| src/Platform/Microsoft.Testing.Platform/Services/ArtifactNamingService.cs | Implements regex-based placeholder resolution and default replacement sources. |
| src/Platform/Microsoft.Testing.Platform/Hosts/TestHostBuilder.cs | Registers ArtifactNamingService into the platform service provider. |
| src/Platform/Microsoft.Testing.Platform/Helpers/System/SystemEnvironment.cs | Implements newly added Version property on the environment wrapper. |
| src/Platform/Microsoft.Testing.Platform/Helpers/System/IEnvironment.cs | Adds Version to the environment abstraction. |
| src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpProcessLifetimeHandler.cs | Uses IArtifactNamingService for dump filename resolution (then applies legacy %p). |
| src/Platform/Microsoft.Testing.Extensions.HangDump/HangDumpExtensions.cs | Wires IArtifactNamingService into HangDumpProcessLifetimeHandler construction. |
| docs/ArtifactNamingService.md | Documents placeholders, custom replacements, and HangDump usage. |
Copilot's findings
- Files reviewed: 11/11 changed files
- Comments generated: 2
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Evangelink <11340282+Evangelink@users.noreply.github.com> Co-authored-by: Amaury Levé <amauryleve@microsoft.com>
# Conflicts: # docs/Changelog.md # eng/Version.Details.xml # eng/Versions.props # global.json
- Use TargetFrameworkAttribute.FrameworkDisplayName before falling back to RuntimeInformation.FrameworkDescription for <tfm> placeholder - Omit <asm> and <tfm> keys from replacements when null instead of using empty string (prevents path traversal with directory templates) - Strengthen subdirectory assertion to verify directory name equals 'HangDump'
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
The test suite is in good shape — previous reviews have successfully addressed the major findings (broken TFM regex, weak ContainsSingle assertion). Two minor coverage gaps remain in the unit tests:
-
[Coverage] No test for repeated placeholder substitution (
ArtifactNamingHelperTests.csline 88) —Regex.Replacewith aMatchEvaluatorreplaces all matches globally, but this global-replace behaviour is never explicitly asserted. A template like<pname>_<pname>.dmpwould be the minimal test. -
[Coverage] No test for a no-placeholder template with non-empty replacements (
ArtifactNamingHelperTests.csline 126) — TheNullReplacements/EmptyReplacementstests short-circuit before the regex runs. A plain template like"simple.dmp"with a non-empty dictionary exercises the regex path with zero matches, which is currently untested.
What looks good
- Isolation: No shared mutable state; every test uses local variables only.
- Error paths: All three invalid-template inputs (null, empty, whitespace) are covered with
Assert.ThrowsExactly. - Case-sensitivity: Explicitly tested and correctly asserted.
- Integration tests:
Guid.NewGuid()per-run directories avoid file-system conflicts;Assert.ContainsSingleused for deterministic dump-file detection; TFM regex correctly usesnet[\w.]+to covernet9.0,net10.0,net462, etc. - Platform assertion:
GetOperatingSystemName_ReturnsKnownValueusesAssert.Containsagainst a closed set and explicitly anchors the three known platforms.
Recommendations
Add the two suggested test cases noted above — they are small and will make the coverage complete.
Generated by Test Expert Reviewer 🧪
🧪 Test quality reviewed by Test Expert Reviewer 🧪
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
[Correctness] <asm>/<tfm> placeholder omission creates invalid Windows paths when resolution fails
The latest commit (5f8bb212) correctly replaced the "" (empty-string) fallback for asm/tfm to fix a path-traversal risk (<asm>/ + empty string → root-relative path on Linux). However, the chosen fix — omitting the key from replacements when the value is null — introduces a symmetric correctness problem on Windows: if the entry assembly is null or TargetFrameworkParser can't parse the framework description, the placeholder remains as literal <asm> or <tfm> in the resolved filename. Path.GetFullPath then throws ArgumentException on Windows because < and > are illegal path characters.
Impact: ArgumentException crash on Windows for any user whose --hangdump-filename template uses <asm> or <tfm> and whose entry assembly or TFM resolution returns null. The <os> placeholder is immune because GetOperatingSystemName() always returns a safe string (falling back to "unknown").
Suggestion: Replace the conditional-add pattern with an unconditional fallback of "unknown", which is already the established pattern for <os>:
replacements["asm"] = asmName ?? "unknown";
replacements["tfm"] = tfm ?? "unknown";Positive Observations
- The overall design of
ArtifactNamingHelperas a file-linked,[Embedded]static class is clean and avoids IVT coupling. ResolveTemplatecorrectly preserves unknown placeholders as-is and is case-sensitive by design.- The static
RegexwithRegexOptions.Compiledis appropriately placed. - The integration tests are thorough, covering both the flat template case and the subdirectory case (with
<asm>/), and the unit tests cover all documented edge cases including case-sensitivity and null/empty inputs. - The legacy
%pbackward compatibility viastring.ReplaceafterResolveTemplateis a clean approach.
Overall Assessment
The PR is in very good shape — all previously flagged issues have been addressed. One narrow correctness issue remains in the new commit's null-handling strategy for <asm> and <tfm>.
Generated by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
- Enforce ordinal (case-sensitive) comparison in ResolveTemplate regardless of caller-provided dictionary comparer - Add path traversal validation: reject resolved paths that escape the results directory (rooted paths or '..' segments) - Use 'unknown' fallback for <asm> and <tfm> instead of omitting them (prevents illegal '<'/'>' characters in Windows paths) - Add tests: repeated placeholder and no-placeholder with replacements
…ub.com/microsoft/testfx into dev/amauryleve/artifact-naming-service
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
[Security] Path traversal guard bypass (HangDumpProcessLifetimeHandler.cs line 336)
The new path-confinement check introduced in commit 50f8d99f uses:
if (!finalDumpFileName.StartsWith(resultsDirectory, StringComparison.Ordinal))resultsDirectory has no trailing path separator (e.g. /tmp/testresults). A template that resolves via .. to a sibling directory with a name that shares the same prefix (e.g. /tmp/testresults_evil/file.dmp) passes the check despite being outside the results directory. The fix is to append Path.DirectorySeparatorChar to the guard string.
Positive Observations
- The
ArtifactNamingHelper.ResolveTemplatelogic is clean and correct: compiled regex avoids repeated JIT cost, unknown placeholders are preserved as-is, ordinal comparison is enforced regardless of the caller's dictionary comparer. - The
GetOperatingSystemNamefallback toRuntimeInformation.IsOSPlatformis compatible with all TFMs includingnet462(noOperatingSystem.IsXxx()guards needed). - Template values (
pname,pid, etc.) are always populated with safe fallbacks ("unknown") preventing<placeholder>literal characters appearing in Windows file names. - The integration tests
HangDump_TemplateFileName_CreateDumpandHangDump_TemplateFileNameWithSubdirectory_CreateDumpprovide good end-to-end coverage of template expansion and subdirectory creation.
Recommendations
- Fix the
StartsWithguard to useresultsDirectory + Path.DirectorySeparatorChar(or aTrimEnd-safe variant) to close the sibling-directory bypass.
Generated by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
-
[Coverage] Path-traversal rejection untested — guard has a bypass (
HangDumpTests.csline 168)
The newStartsWith-based security check inTakeDumpAsyncis neither unit-tested nor integration-tested. Worse, a sibling-directory attack (/tmp/results-evil/hang.dmppasses the/tmp/resultsprefix check) slips through because no trailing separator is appended before the comparison. A test would both catch the missing code path and expose the bypass. -
[Coverage] Ordinal comparer normalization never exercised (
ArtifactNamingHelperTests.csline 92)
The copy-to-ordinal-dict branch added in this commit is not reached by any test. A caller providing aStringComparer.OrdinalIgnoreCasedict with uppercase keys (e.g.["PNAME"]) would see their key NOT matched against<pname>, but this behavior is currently invisible to the test suite. -
[Structure] Misleading test name (
ArtifactNamingHelperTests.csline 30)
WithCustomValues_OverridesCorrectlyimplies override-of-defaults semantics, but the template has no overridable defaults —_customis just a literal string suffix.
Recommendations
- Fix the
StartsWithpath-traversal bypass by appendingPath.DirectorySeparatorChar(or usingPath.GetRelativePath+ leading..check) and add a test that exercises the rejection path. - Add a test for the ordinal-copy path (non-ordinal dict + uppercase key).
- Rename
ResolveTemplate_WithCustomValues_OverridesCorrectlyto reflect actual intent.
Generated by Test Expert Reviewer 🧪
🧪 Test quality reviewed by Test Expert Reviewer 🧪
- Fix path traversal guard: append trailing separator to prevent sibling- directory bypass (e.g. /tmp/results vs /tmp/results-evil) - Add test for ordinal normalization of case-insensitive dictionaries - Rename test to ResolveTemplate_LiteralTextAndPlaceholders_ReplacesOnlyPlaceholders
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
The test additions in this revision are well-structured and comprehensive. All previously flagged issues have been resolved.
Unit tests (ArtifactNamingHelperTests.cs):
- Full coverage of
ResolveTemplate: happy path, empty/null/whitespace template (throws), unknown placeholders preserved, case-sensitivity, repeated placeholders, null/empty replacements,IDictionarywith non-ordinal comparer. GetOperatingSystemNameis platform-anchored with explicit per-OS assertions.- No isolation issues — fully stateless static method under test, no shared mutable state.
Assert.ThrowsExactly<ArgumentException>is used correctly for input validation paths.
Integration tests (HangDumpTests.cs):
HangDump_TemplateFileName_CreateDump: Verifies template expansion end-to-end with<pname>_<pid>_<tfm>_<time>_hang.dmp, using a well-formed regex and a targeted TFM presence check.HangDump_TemplateFileNameWithSubdirectory_CreateDump: Verifies<asm>/...expansion creates the expected subdirectory, with explicitPath.GetFileNameassertions.- Both new integration tests use
DynamicData(AllForDynamicData)for cross-TFM coverage,Guid.NewGuid()result directories for isolation, andTestContext.CancellationTokenfor CI-safe execution — all consistent with repo conventions. SLEEPTIMEMS2 = "20000"leaves an ~8-second window before the second sleep completes; the hang dump fires at ~12 s (4 s + 8 s timeout), which is well within that window and also makes failures faster to diagnose (rather than waiting 10 minutes on a broken hang dump mechanism).
Recommendations
No action required. The test suite is reliable, isolated, and covers all meaningful branches.
Generated by Test Expert Reviewer 🧪
🧪 Test quality reviewed by Test Expert Reviewer 🧪
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
✅ Previous Security Finding Fixed (Path Traversal): The trailing-separator bypass reported in the previous run (/tmp/results vs /tmp/results-evil) has been correctly addressed. Path.GetFullPath is now applied to both paths and resultsDirectoryGuard uses a trailing separator, closing the sibling-directory escape vector.
ResolveTemplate documents that unknown <placeholder> tokens are "preserved as-is", but on Windows < and > are illegal path characters. Path.GetFullPath throws ArgumentException: Illegal characters in path before reaching the user-friendly InvalidOperationException guard. On Linux the same template silently embeds <foo> literally in the filename. A user with a typo gets a cryptic internal error on Windows, a silent oddity on Linux — asymmetric, undocumented, and actionable-error-breaking behavior.
When a template like <asm>/<pname>_<pid>_hang.dmp places the dump in a subdirectory, the companion .log file (in-progress tests) is always written to the root results directory via _configuration.GetTestResultDirectory() + Path.GetFileName(finalDumpFileName). This means the dump and its log land in different places. The second GetTestResultDirectory() call also doesn't reuse the already-normalized resultsDirectory local variable.
Positive Observations
- The compiled
Regexstatic field avoids per-call allocation. ✅ - Ordinal dictionary normalization pattern in
ResolveTemplatecorrectly prevents case-insensitive dict bypass. ✅ Path.GetFullPath+ trailing-separator guard correctly neutralizes path traversal from both rooted paths and..segments. ✅Directory.CreateDirectorybefore dump creation correctly handles subdirectory templates without an extra existence check. ✅RuntimeInformation.IsOSPlatformis available on all target frameworks (netstandard2.0, net462, net8.0+) — no cross-TFM issue. ✅- Regex replacement is non-recursive (substituted values are not re-expanded), preventing injection through process name or assembly name values. ✅
Recommendations
- Add a pre-
Path.GetFullPathvalidation that the resolved pattern contains no remaining<...>tokens, with a clear error message listing supported placeholders. - Co-locate the
.logfile with the dump (Path.GetDirectoryName(finalDumpFileName)!) instead of always writing to the root results directory, and use the pre-computedresultsDirectoryvariable.
Generated by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
On Windows, paths are case-insensitive so use OrdinalIgnoreCase for the StartsWith check to avoid false rejections when casing differs.
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Test Expert Reviewer 🧪
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
The new ArtifactNamingHelperTests.cs is thorough and covers the main scenarios well (null/empty/whitespace templates, case sensitivity, repeated placeholders, unknown placeholders, dictionary-type variants). The integration tests in HangDumpTests.cs follow established patterns (unique result directories via Guid.NewGuid(), TestContext.CancellationToken, Assert.ContainsSingle).
The following gaps were identified:
-
Untestable null-guard branch (
value is not nullinArtifactNamingHelper.csline 37) — never exercised becauseIDictionary<string, string>has non-nullable values. The guard should either be removed (trust the type annotation) or covered with a test using a customIDictionaryimplementation that can returnnull. -
Missing edge case: empty placeholder
<>— the regex<([^>]+)>requires ≥1 character, so<>is preserved as-is, but no test documents or protects this behavior. -
CaseInsensitiveDictionarytest covers only the non-matching case — the complementary scenario (lowercase key in an OrdinalIgnoreCase dict correctly resolves<pname>after ordinal copy) is untested. -
<os>placeholder not exercised in integration tests — all other five documented placeholders (<pname>,<pid>,<asm>,<tfm>,<time>) have integration-level coverage;<os>is tested only in unit isolation.
Recommendations
- Decide on the
value is not nullguard: either remove it (it's dead code with the current API) or add a targeted test with a customIDictionary. - Add a test for
<>to pin the "empty placeholder is preserved" behavior. - Add the complementary
CaseInsensitiveDictionarytest to cover both sides of the ordinal-copy path. - Add
<os>to one of the two new integration test templates to close the end-to-end coverage gap.
No isolation bugs, flakiness patterns, false-positive assertions, or test-order dependencies were found. The use of Guid.NewGuid() for unique result directories, TestContext.CancellationToken, and the [DynamicData] approach are all correct.
Generated by Test Expert Reviewer
🧪 Test quality reviewed by Test Expert Reviewer 🧪
Evangelink
left a comment
There was a problem hiding this comment.
Summary
Workflow: Expert Code Reviewer 🧠
Date: 2026-05-12
Repository: microsoft/testfx
Key Findings
-
[Correctness — Medium]
.logco-location breaks with subdirectory templates.
When<asm>/<pname>_hang.dmpis used, the dump lands inresults/TestAssembly/proc.dmpbut the hanging-test list log still goes toresults/proc.log(viaPath.GetFileName). The companion files are now in different directories and the subdirectory integration test doesn't assert on the log placement. -
[Defensive — Low] The
InvalidOperationExceptionerror message for path-escape rejection embeds the fully-resolved dump path, which is derived fromprocess.Name(OS-supplied) and the user-provided--hangdump-filenameflag. In most environments this only surfaces in developer output, but an unbounded string from an adversarially-named process could produce very long messages.
Positive Observations
- Security guard is solid:
Path.GetFullPathnormalization +StartsWith(resultsDirectoryGuard, pathComparison)correctly catches rooted paths,..traversal, and Windows vs. Unix case sensitivity. The sibling-directory bypass (/resultsvs./results-evil) is explicitly prevented with the trailing-separator trick. - Cross-TFM correctness:
RuntimeInformation.IsOSPlatformis used throughout instead ofOperatingSystem.IsXxx()— builds correctly onnet462/netstandard2.0. - Exception handling:
TakeDumpAsyncis called inside atry/catch (Exception)at the call site, so the newInvalidOperationExceptionfrom the path guard is caught and reported gracefully rather than crashing the handler. ResolveTemplatecorrectness: Single-passRegex.Replaceprevents re-injection through replacement values; the ordinal-comparer enforcement correctly uses the singleton reference check; unknown placeholders are preserved as documented.- Backward compatibility: Legacy
%pis handled after template resolution, maintaining existing behaviour for users who haven't migrated to the new<pid>placeholder.
Recommendations
- Fix
.logco-location: changePath.GetFileName(finalDumpFileName)to usefinalDumpFileNamedirectly so log and dump stay in the same directory regardless of template shape. (Inline comment left at the relevant line.)
Generated by Expert Code Reviewer 🧠
🧠 Reviewed by Expert Code Reviewer 🧠
…e resolution, add tests
- Remove {os} placeholder (start small per review feedback)
- Move placeholder value resolution into ArtifactNamingHelper.GetStandardReplacements()
so consumers don't duplicate asm/tfm/time resolution logic
- Use GeneratedRegex on .NET, fall back to RegexOptions.Compiled on netstandard2.0
- Add GetStandardReplacements unit test
- Add path-traversal rejection integration test
- Update docs: clarify {tfm} is best-effort runtime-detected
- Fix PR description: case-sensitive (not case-insensitive)
Summary
Add
ArtifactNamingHelper— a reusable static helper that allows extensions to generate consistent artifact file names using template placeholders.Users can specify templates like:
{pname}_{pid}_{time}_hang.dmp→MyTests_12345_2025-09-22_13-49-34.0000000_hang.dmp{asm}_{tfm}_{time}.log→MyTests_net9.0_2025-09-22_13-49-34.0000000.logAvailable Placeholders
{pname}MyTests{pid}12345{asm}unknownif unavailable)MyTests{tfm}net9.0{time}2025-09-22_13-49-34.0000000Key Design Decisions
ArtifactNamingHelperis a shared static class, compiled into each extension via file linking (no service registration or IVT required)GetStandardReplacements()provides all standard placeholder values so consumers don't duplicate resolution logic{pname}, not{PName})RegexOptions.Compiledon netstandard2.0)%ppattern handled in the HangDump caller viastring.ReplaceChanges
ArtifactNamingHelperstatic class (file-linked into extensions)HangDumpProcessLifetimeHandlerupdated to use the helperdocs/microsoft.testing.platform/ArtifactNamingHelper.mdNote
This PR revives and fixes the content from #6587 (closed copilot PR), with extensive code review fixes:
netstandard2.0(removedOperatingSystem.IsXxx(),KeyValuePairdeconstruction,[..8]range syntax)Guard.NotNullOrEmptyAPI%phandling{project}placeholder (directory tree traversal)EnvironmentAPI usage (RS0030)Fixes #6586
Related to #5364, #7345, #6648, #4130, #7126, #6778