From e1a52d88cee39b6eacaa8c35720657893aafa7ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Fri, 10 Jul 2026 00:09:00 +0200 Subject: [PATCH 01/26] Add artifact merge engines and Kind metadata flow (RFC 018 phase 1) Implements the first testfx-side pieces of RFC 018 (artifact post-processing for dotnet test / MTP): - Report merge engines (pure, invocation-agnostic, no I/O in the core): - TrxReportEngine.Merge / MergeToFileAsync (XML-level TRX merge). - JUnitReportMerger (unions , counters derived per-suite). - CtrfReportMerger (JSON merge; summary derived from merged tests[]). - Kind metadata flow so the dotnet/sdk orchestrator can group artifacts by format: SessionFileArtifact gains an optional kind (experimental), the FileArtifactMessage IPC record + serializer carry Kind (field id 7), the dotnet-test consumer propagates it, and report producers tag their kind (trx/junit/ctrf/html). Unit tests cover all three merge engines and the IPC round-trip. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportGenerator.cs | 2 + .../CtrfReportMerger.cs | 220 ++++++++++++++ .../InternalAPI/InternalAPI.Unshipped.txt | 4 + .../InternalAPI/InternalAPI.Unshipped.txt | 1 + .../HtmlReportGenerator.cs | 2 + .../InternalAPI/InternalAPI.Unshipped.txt | 1 + .../InternalAPI/InternalAPI.Unshipped.txt | 4 + .../JUnitReportGenerator.cs | 2 + .../JUnitReportMerger.cs | 169 +++++++++++ .../InternalAPI.Unshipped.txt | 1 + .../InternalAPI/InternalAPI.Unshipped.txt | 1 + .../InternalAPI/InternalAPI.Unshipped.txt | 3 + .../TrxDataConsumer.cs | 2 +- .../TrxReportEngine.Merge.cs | 276 ++++++++++++++++++ .../InternalAPI/InternalAPI.Unshipped.txt | 7 + .../Messages/FileArtifacts.cs | 35 +++ .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../DotnetTest/IPC/DotnetTestDataConsumer.cs | 9 +- .../IPC/Models/FileArtifactMessages.cs | 2 +- .../DotnetTest/IPC/ObjectFieldIds.cs | 1 + .../FileArtifactMessagesSerializer.cs | 12 +- .../ReportGeneratorBase.cs | 11 +- .../CtrfReportMergerTests.cs | 208 +++++++++++++ .../JUnitReportMergerTests.cs | 167 +++++++++++ .../TrxReportEngineMergeTests.cs | 264 +++++++++++++++++ .../DotnetTestProtocolSerializerTests.cs | 6 +- .../IPC/ProtocolEdgeCaseTests.cs | 8 +- .../IPC/ProtocolTests.cs | 4 +- 28 files changed, 1408 insertions(+), 16 deletions(-) create mode 100644 src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs create mode 100644 src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs create mode 100644 test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs index d29ea1056b..d03025d55f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportGenerator.cs @@ -24,6 +24,8 @@ public CtrfReportGenerator(IServiceProvider serviceProvider) protected override string ArtifactDisplayName => ExtensionResources.CtrfReportArtifactDisplayName; + protected override string? ArtifactKind => "microsoft.testing.ctrf"; + protected override string ArtifactDescription => ExtensionResources.CtrfReportArtifactDescription; protected override string GetGenerationLogMessage(int testResultCount) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs new file mode 100644 index 0000000000..fa9a23d4eb --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json; +using System.Text.Json.Nodes; + +using Microsoft.Testing.Platform; + +namespace Microsoft.Testing.Extensions.CtrfReport; + +/// +/// Merges several already-produced CTRF JSON reports into a single CTRF document. +/// +/// +/// This is a pure, invocation-agnostic JSON-level merge (no I/O, no clock) that mirrors the +/// TRX and JUnit mergers, demonstrating that the same post-processing shape fits a JSON format: +/// +/// results.tests[] arrays are concatenated as-is. +/// results.summary counters are re-derived by counting the merged tests[] (so summary.tests always matches the array length); start/stop use the earliest/latest across inputs, duration is the resulting span. +/// reportFormat, specVersion, tool and environment are taken from the first report; reportId is freshly generated. +/// +/// +internal static class CtrfReportMerger +{ + internal static string Merge(IReadOnlyList inputReports) + { + if (inputReports is null) + { + throw new ArgumentNullException(nameof(inputReports)); + } + + if (inputReports.Count == 0) + { + throw new ArgumentException("At least one CTRF report is required to merge.", nameof(inputReports)); + } + + JsonObject? first = null; + var mergedTests = new JsonArray(); + + long? earliestStart = null; + long? latestStop = null; + + foreach (string reportJson in inputReports) + { + if (JsonNode.Parse(reportJson) is not JsonObject root) + { + continue; + } + + first ??= root; + + JsonNode? results = root["results"]; + if (results?["tests"] is JsonArray testArray) + { + foreach (JsonNode? test in testArray) + { + mergedTests.Add(test?.DeepClone()); + } + } + + JsonNode? summary = results?["summary"]; + if (summary is not null) + { + if (TryReadLong(summary, "start", out long start) && (earliestStart is null || start < earliestStart)) + { + earliestStart = start; + } + + if (TryReadLong(summary, "stop", out long stop) && (latestStop is null || stop > latestStop)) + { + latestStop = stop; + } + } + } + + if (first is null) + { + throw new ArgumentException("None of the provided inputs were valid CTRF reports.", nameof(inputReports)); + } + + long startMs = earliestStart ?? 0; + long stopMs = latestStop ?? startMs; + + // Counters are derived from the merged tests[] rather than trusting each input's summary, so + // summary.tests always equals the array length even when an input omitted or under-reported + // its summary. + long passed = 0, failed = 0, skipped = 0, pending = 0, other = 0, flaky = 0; + foreach (JsonNode? test in mergedTests) + { + if (test is null) + { + continue; + } + + switch ((string?)test["status"]) + { + case "passed": passed++; break; + case "failed": failed++; break; + case "skipped": skipped++; break; + case "pending": pending++; break; + default: other++; break; + } + + if (test["flaky"] is JsonValue flakyValue && flakyValue.TryGetValue(out bool isFlaky) && isFlaky) + { + flaky++; + } + } + + var summaryObject = new JsonObject + { + ["tests"] = mergedTests.Count, + ["passed"] = passed, + ["failed"] = failed, + ["skipped"] = skipped, + ["pending"] = pending, + ["other"] = other, + ["flaky"] = flaky, + ["start"] = startMs, + ["stop"] = stopMs, + ["duration"] = Math.Max(0, stopMs - startMs), + }; + + var resultsObject = new JsonObject(); + if (first["results"]?["tool"] is JsonNode tool) + { + resultsObject["tool"] = tool.DeepClone(); + } + + resultsObject["summary"] = summaryObject; + + if (first["results"]?["environment"] is JsonNode environment) + { + resultsObject["environment"] = environment.DeepClone(); + } + + resultsObject["tests"] = mergedTests; + + var merged = new JsonObject + { + ["reportFormat"] = first["reportFormat"]?.DeepClone() ?? "CTRF", + ["specVersion"] = first["specVersion"]?.DeepClone() ?? "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stopMs).ToString("O", CultureInfo.InvariantCulture), + ["results"] = resultsObject, + }; + + if (first["generatedBy"] is JsonNode generatedBy) + { + merged["generatedBy"] = generatedBy.DeepClone(); + } + + return merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); + } + + internal static async Task MergeToFileAsync( + IReadOnlyList inputPaths, + string outputPath, + CancellationToken cancellationToken) + { + if (inputPaths is null) + { + throw new ArgumentNullException(nameof(inputPaths)); + } + + if (outputPath is null) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + var reports = new List(inputPaths.Count); + foreach (string inputPath in inputPaths) + { + cancellationToken.ThrowIfCancellationRequested(); +#if NETCOREAPP + reports.Add(await File.ReadAllTextAsync(inputPath, cancellationToken).ConfigureAwait(false)); +#else + reports.Add(File.ReadAllText(inputPath)); +#endif + } + + string merged = Merge(reports); + + string? outputDirectory = Path.GetDirectoryName(outputPath); + if (!RoslynString.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + +#if NETCOREAPP + await File.WriteAllTextAsync(outputPath, merged, cancellationToken).ConfigureAwait(false); +#else + File.WriteAllText(outputPath, merged); + await Task.CompletedTask.ConfigureAwait(false); +#endif + } + + private static bool TryReadLong(JsonNode summary, string propertyName, out long value) + { + value = 0; + if (summary[propertyName] is not JsonValue jsonValue) + { + return false; + } + + if (jsonValue.TryGetValue(out long longValue)) + { + value = longValue; + return true; + } + + if (jsonValue.TryGetValue(out double doubleValue)) + { + value = (long)doubleValue; + return true; + } + + return false; + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt index 7dc5c58110..1c802a29ce 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +virtual Microsoft.Testing.Extensions.ReportGeneratorBase.ArtifactKind.get -> string? +Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger +static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.Merge(System.Collections.Generic.IReadOnlyList! inputReports) -> string! +static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt index c84445ece8..268b585924 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.HangDump/InternalAPI/InternalAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable +const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs index 3d08ec79d9..1debfa387f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/HtmlReportGenerator.cs @@ -24,6 +24,8 @@ public HtmlReportGenerator(IServiceProvider serviceProvider) protected override string ArtifactDisplayName => ExtensionResources.HtmlReportArtifactDisplayName; + protected override string? ArtifactKind => "microsoft.testing.html"; + protected override string ArtifactDescription => ExtensionResources.HtmlReportArtifactDescription; protected override string GetGenerationLogMessage(int testResultCount) diff --git a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt index 7dc5c58110..2bb2c4a228 100644 --- a/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.HtmlReport/InternalAPI/InternalAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +virtual Microsoft.Testing.Extensions.ReportGeneratorBase.ArtifactKind.get -> string? diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt index 7dc5c58110..2a05616510 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt @@ -1 +1,5 @@ #nullable enable +virtual Microsoft.Testing.Extensions.ReportGeneratorBase.ArtifactKind.get -> string? +Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger +static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.Merge(System.Collections.Generic.IReadOnlyList! inputReports, string! reportName) -> System.Xml.Linq.XDocument! +static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, string! reportName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs index 34601e8e79..c8fc0ac642 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportGenerator.cs @@ -33,6 +33,8 @@ public JUnitReportGenerator(IServiceProvider serviceProvider) protected override string ArtifactDisplayName => ExtensionResources.JUnitReportArtifactDisplayName; + protected override string? ArtifactKind => "microsoft.testing.junit"; + protected override string ArtifactDescription => ExtensionResources.JUnitReportArtifactDescription; protected override string GetGenerationLogMessage(int testResultCount) diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs new file mode 100644 index 0000000000..6bb7f2f341 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -0,0 +1,169 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform; + +namespace Microsoft.Testing.Extensions.JUnitReport; + +/// +/// Merges several already-produced JUnit XML reports into a single JUnit document. +/// +/// +/// This is a pure, invocation-agnostic XML-level merge (no I/O, no clock) that mirrors the +/// approach used for TRX: a user-facing merge tool and an SDK-orchestrated post-processor can +/// share it and, given the same inputs and reportName, produce deterministic output. +/// +/// Merge rules: +/// +/// Every <testsuite> element is unioned as-is and re-assigned a sequential id. Both <testsuites>-rooted documents and bare <testsuite>-rooted documents are supported; any other root is skipped. +/// Root tests/failures/errors/skipped/time counters are derived by summing the per-suite counters, so they are correct even when an input's root aggregates are missing. +/// The root timestamp is the earliest across all merged suites. +/// +/// +/// +internal static class JUnitReportMerger +{ + private const string RootElementName = "testsuites"; + private const string SuiteElementName = "testsuite"; + + internal static XDocument Merge(IReadOnlyList inputReports, string reportName) + { + if (inputReports is null) + { + throw new ArgumentNullException(nameof(inputReports)); + } + + if (reportName is null) + { + throw new ArgumentNullException(nameof(reportName)); + } + + if (inputReports.Count == 0) + { + throw new ArgumentException("At least one JUnit report is required to merge.", nameof(inputReports)); + } + + long totalTests = 0; + long totalFailures = 0; + long totalErrors = 0; + long totalSkipped = 0; + double totalTime = 0; + DateTimeOffset? earliestTimestamp = null; + + var mergedRoot = new XElement(RootElementName); + int suiteId = 0; + + foreach (XDocument report in inputReports) + { + XElement? root = report.Root; + if (root is null) + { + continue; + } + + // Support both -rooted documents and a bare root (a valid, + // common JUnit shape); any other root has no suites to contribute and is skipped. + IEnumerable suites = string.Equals(root.Name.LocalName, RootElementName, StringComparison.Ordinal) + ? root.Elements().Where(e => string.Equals(e.Name.LocalName, SuiteElementName, StringComparison.Ordinal)) + : string.Equals(root.Name.LocalName, SuiteElementName, StringComparison.Ordinal) + ? [root] + : []; + + foreach (XElement suite in suites) + { + var clonedSuite = new XElement(suite); + clonedSuite.SetAttributeValue("id", suiteId++); + mergedRoot.Add(clonedSuite); + + // Derive aggregates from the per-suite counters rather than trusting the (optional) + // root aggregates, so a merge cannot silently under-count. + totalTests += ReadLong(suite, "tests"); + totalFailures += ReadLong(suite, "failures"); + totalErrors += ReadLong(suite, "errors"); + totalSkipped += ReadLong(suite, "skipped"); + totalTime += ReadDouble(suite, "time"); + + if (TryReadTimestamp(suite, "timestamp", out DateTimeOffset timestamp) + && (earliestTimestamp is null || timestamp < earliestTimestamp)) + { + earliestTimestamp = timestamp; + } + } + } + + mergedRoot.SetAttributeValue("name", reportName); + mergedRoot.SetAttributeValue("tests", totalTests.ToString(CultureInfo.InvariantCulture)); + mergedRoot.SetAttributeValue("failures", totalFailures.ToString(CultureInfo.InvariantCulture)); + mergedRoot.SetAttributeValue("errors", totalErrors.ToString(CultureInfo.InvariantCulture)); + mergedRoot.SetAttributeValue("skipped", totalSkipped.ToString(CultureInfo.InvariantCulture)); + mergedRoot.SetAttributeValue("time", totalTime.ToString("0.000", CultureInfo.InvariantCulture)); + if (earliestTimestamp is { } stamp) + { + mergedRoot.SetAttributeValue("timestamp", stamp.UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)); + } + + return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedRoot); + } + + internal static async Task MergeToFileAsync( + IReadOnlyList inputPaths, + string outputPath, + string reportName, + CancellationToken cancellationToken) + { + if (inputPaths is null) + { + throw new ArgumentNullException(nameof(inputPaths)); + } + + if (outputPath is null) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + var reports = new List(inputPaths.Count); + foreach (string inputPath in inputPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + reports.Add(XDocument.Load(inputPath)); + } + + XDocument merged = Merge(reports, reportName); + + string? outputDirectory = Path.GetDirectoryName(outputPath); + if (!RoslynString.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + using FileStream stream = File.Create(outputPath); +#if NETCOREAPP + await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); +#else + merged.Save(stream, SaveOptions.None); + await Task.CompletedTask.ConfigureAwait(false); +#endif + } + + private static long ReadLong(XElement element, string attributeName) + => long.TryParse(element.Attribute(attributeName)?.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value) + ? value + : 0; + + private static double ReadDouble(XElement element, string attributeName) + => double.TryParse(element.Attribute(attributeName)?.Value, NumberStyles.Float, CultureInfo.InvariantCulture, out double value) + ? value + : 0; + + private static bool TryReadTimestamp(XElement element, string attributeName, out DateTimeOffset result) + { + string? value = element.Attribute(attributeName)?.Value; + if (RoslynString.IsNullOrEmpty(value)) + { + result = default; + return false; + } + + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result); + } +} diff --git a/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt index c84445ece8..268b585924 100644 --- a/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.MSBuild/InternalAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable +const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt index c84445ece8..268b585924 100644 --- a/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.Retry/InternalAPI/InternalAPI.Unshipped.txt @@ -1,3 +1,4 @@ #nullable enable +const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt index c84445ece8..b7472af6e6 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt @@ -1,3 +1,6 @@ #nullable enable +const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort +static Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.Merge(System.Collections.Generic.IReadOnlyList! inputReports, System.Guid runId, string! runName) -> System.Xml.Linq.XDocument! +static Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, System.Guid runId, string! runName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.ReadFields(System.IO.Stream! stream, System.Func! tryReadField) -> void static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs index 2ca1a6c778..1efd36fd4f 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs @@ -251,7 +251,7 @@ await _outputDisplay.DisplayAsync( // If we are running with out-of-process mode, we communicate via pipe to the TestHostController and send the ReportFileNameRequest. if (!TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptionsService)) { - await _messageBus.PublishAsync(this, new SessionFileArtifact(testSessionContext.SessionUid, new FileInfo(reportFileName), ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription)).ConfigureAwait(false); + await _messageBus.PublishAsync(this, new SessionFileArtifact(testSessionContext.SessionUid, new FileInfo(reportFileName), ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription, "microsoft.testing.trx")).ConfigureAwait(false); } else { diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs new file mode 100644 index 0000000000..6a39eac726 --- /dev/null +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -0,0 +1,276 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform; + +namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; + +internal sealed partial class TrxReportEngine +{ + /// + /// Merges several already-produced TRX reports into a single TRX document. + /// + /// + /// This is a pure, invocation-agnostic XML-level merge: it does no I/O and reads no clock, so a + /// user-facing merge tool and an SDK-orchestrated post-processor can share it and, given the same + /// / and inputs, produce equivalent output. The + /// only non-deterministic element is the freshly generated TestSettings id. + /// + /// Merge rules: + /// + /// Results, TestDefinitions and TestEntries are unioned as-is. + /// TestLists are deduplicated by id (the well-known lists are shared across files). + /// Counters attributes are summed; Times use the earliest start and latest finish. + /// The result summary outcome is Failed if any input failed, otherwise Completed. + /// Attachment/result-file paths are preserved verbatim (they are absolute today). + /// + /// + /// + internal static XDocument Merge(IReadOnlyList inputReports, Guid runId, string runName) + { + if (runName is null) + { + throw new ArgumentNullException(nameof(runName)); + } + + if (inputReports is null) + { + throw new ArgumentNullException(nameof(inputReports)); + } + + if (inputReports.Count == 0) + { + throw new ArgumentException("At least one TRX report is required to merge.", nameof(inputReports)); + } + + var mergedResults = new XElement(NamespaceUri + "Results"); + var mergedTestDefinitions = new XElement(NamespaceUri + "TestDefinitions"); + var mergedTestEntries = new XElement(NamespaceUri + "TestEntries"); + var mergedTestLists = new XElement(NamespaceUri + "TestLists"); + var seenTestListIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Preserve the order in which counter attributes are first seen so the merged + // element keeps the well-known TRX attribute ordering. + var counterAttributeOrder = new List(); + var counterSums = new Dictionary(StringComparer.Ordinal); + + bool anyFailure = false; + DateTimeOffset? earliestStart = null; + DateTimeOffset? latestFinish = null; + + foreach (XDocument report in inputReports) + { + XElement? testRun = report.Root; + if (testRun is null) + { + continue; + } + + CloneChildrenInto(FindChild(testRun, "Results"), mergedResults); + CloneChildrenInto(FindChild(testRun, "TestDefinitions"), mergedTestDefinitions); + CloneChildrenInto(FindChild(testRun, "TestEntries"), mergedTestEntries); + + XElement? testLists = FindChild(testRun, "TestLists"); + if (testLists is not null) + { + foreach (XElement testList in testLists.Elements()) + { + string? id = testList.Attribute("id")?.Value; + if (id is null || seenTestListIds.Add(id)) + { + mergedTestLists.Add(new XElement(testList)); + } + } + } + + XElement? resultSummary = FindChild(testRun, "ResultSummary"); + if (resultSummary is not null) + { + if (string.Equals(resultSummary.Attribute("outcome")?.Value, "Failed", StringComparison.OrdinalIgnoreCase)) + { + anyFailure = true; + } + + AccumulateCounters(FindChild(resultSummary, "Counters"), counterAttributeOrder, counterSums); + } + + XElement? times = FindChild(testRun, "Times"); + if (times is not null) + { + if (TryParseDateTimeOffset(times.Attribute("start")?.Value, out DateTimeOffset start) + && (earliestStart is null || start < earliestStart)) + { + earliestStart = start; + } + + if (TryParseDateTimeOffset(times.Attribute("finish")?.Value, out DateTimeOffset finish) + && (latestFinish is null || finish > latestFinish)) + { + latestFinish = finish; + } + } + } + + if (counterSums.TryGetValue("failed", out long failedCount) && failedCount > 0) + { + anyFailure = true; + } + + if (counterSums.TryGetValue("timeout", out long timeoutCount) && timeoutCount > 0) + { + anyFailure = true; + } + + var mergedTestRun = new XElement( + NamespaceUri + "TestRun", + new XAttribute("id", runId), + new XAttribute("name", runName)); + + mergedTestRun.Add(BuildTimes(earliestStart, latestFinish)); + mergedTestRun.Add(BuildTestSettings(runName)); + mergedTestRun.Add(mergedResults); + mergedTestRun.Add(mergedTestDefinitions); + mergedTestRun.Add(mergedTestEntries); + mergedTestRun.Add(mergedTestLists); + mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums)); + + return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedTestRun); + } + + /// + /// Loads the given TRX files, merges them (see ) and writes the result to . + /// + internal static async Task MergeToFileAsync( + IReadOnlyList inputPaths, + string outputPath, + Guid runId, + string runName, + CancellationToken cancellationToken) + { + if (inputPaths is null) + { + throw new ArgumentNullException(nameof(inputPaths)); + } + + if (outputPath is null) + { + throw new ArgumentNullException(nameof(outputPath)); + } + + var reports = new List(inputPaths.Count); + foreach (string inputPath in inputPaths) + { + cancellationToken.ThrowIfCancellationRequested(); + reports.Add(XDocument.Load(inputPath)); + } + + XDocument merged = Merge(reports, runId, runName); + + string? outputDirectory = Path.GetDirectoryName(outputPath); + if (!RoslynString.IsNullOrEmpty(outputDirectory)) + { + Directory.CreateDirectory(outputDirectory); + } + + using FileStream stream = File.Create(outputPath); +#if NETCOREAPP + await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); +#else + merged.Save(stream, SaveOptions.None); + await Task.CompletedTask.ConfigureAwait(false); +#endif + } + + private static XElement? FindChild(XElement parent, string localName) + => parent.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, localName, StringComparison.Ordinal)); + + private static void CloneChildrenInto(XElement? source, XElement destination) + { + if (source is null) + { + return; + } + + foreach (XElement child in source.Elements()) + { + destination.Add(new XElement(child)); + } + } + + private static void AccumulateCounters(XElement? counters, List attributeOrder, Dictionary sums) + { + if (counters is null) + { + return; + } + + foreach (XAttribute attribute in counters.Attributes()) + { + string name = attribute.Name.LocalName; + if (!sums.ContainsKey(name)) + { + attributeOrder.Add(name); + sums[name] = 0; + } + + if (long.TryParse(attribute.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long value)) + { + sums[name] += value; + } + } + } + + private static XElement BuildTimes(DateTimeOffset? earliestStart, DateTimeOffset? latestFinish) + { + var times = new XElement(NamespaceUri + "Times"); + if (earliestStart is { } start) + { + times.SetAttributeValue("creation", start); + times.SetAttributeValue("queuing", start); + times.SetAttributeValue("start", start); + } + + if (latestFinish is { } finish) + { + times.SetAttributeValue("finish", finish); + } + + return times; + } + + private static XElement BuildTestSettings(string runName) + { + var testSettings = new XElement( + NamespaceUri + "TestSettings", + new XAttribute("name", "default"), + new XAttribute("id", Guid.NewGuid())); + string runDeploymentRoot = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); + testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", runDeploymentRoot))); + return testSettings; + } + + private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums) + { + var counters = new XElement(NamespaceUri + "Counters"); + foreach (string name in counterAttributeOrder) + { + counters.SetAttributeValue(name, counterSums[name].ToString(CultureInfo.InvariantCulture)); + } + + return new XElement( + NamespaceUri + "ResultSummary", + new XAttribute("outcome", outcome), + counters); + } + + private static bool TryParseDateTimeOffset(string? value, out DateTimeOffset result) + { + if (RoslynString.IsNullOrEmpty(value)) + { + result = default; + return false; + } + + return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out result); + } +} diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index 7676911637..f4a64a4df1 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -1,4 +1,11 @@ #nullable enable +const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Deconstruct(out string? FullPath, out string? DisplayName, out string? Description, out string? TestUid, out string? TestDisplayName, out string? SessionUid, out string? Kind) -> void +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid, string? Kind) -> void +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Kind.get -> string? +Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Kind.init -> void +*REMOVED*Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Deconstruct(out string? FullPath, out string? DisplayName, out string? Description, out string? TestUid, out string? TestDisplayName, out string? SessionUid) -> void +*REMOVED*Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid) -> void const Microsoft.Testing.Platform.ServerMode.JsonRpcStrings.IsStateful = "isStateful" -> string! Microsoft.Testing.Platform.ServerMode.ClientCapabilities.ClientCapabilities(bool DebuggerProvider, bool IsStateful) -> void Microsoft.Testing.Platform.ServerMode.ClientCapabilities.Deconstruct(out bool DebuggerProvider, out bool IsStateful) -> void diff --git a/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs b/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs index 1a3e92bc11..c3bfc09b77 100644 --- a/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs +++ b/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs @@ -73,14 +73,49 @@ public class SessionFileArtifact : DataWithSessionUid /// The file information. /// The display name. /// The description. + // This shipped constructor intentionally keeps its optional 'description' parameter for binary + // compatibility; the newer overload below adds 'kind'. Folding them into a single constructor + // would binary-break existing compiled callers of this 4-parameter overload, so RS0027 (which + // wants the optional-parameter overload to have the most parameters) is suppressed here. +#pragma warning disable RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads public SessionFileArtifact(SessionUid sessionUid, FileInfo fileInfo, string displayName, string? description = null) +#pragma warning restore RS0027 : base(displayName, description, sessionUid) => FileInfo = fileInfo; + /// + /// Initializes a new instance of the class with a producer-asserted artifact kind. + /// + /// The session UID. + /// The file information. + /// The display name. + /// The description. + /// + /// An optional producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx). Used by post-processing to group artifacts of + /// the same kind for consolidation. when the producer does not + /// declare a kind. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public SessionFileArtifact(SessionUid sessionUid, FileInfo fileInfo, string displayName, string? description, string? kind) + : base(displayName, description, sessionUid) + { + FileInfo = fileInfo; + Kind = kind; + } + /// /// Gets the file information. /// public FileInfo FileInfo { get; } + /// + /// Gets the producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx), or when the producer + /// did not declare one. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public string? Kind { get; } + /// public override string ToString() { diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 01eaf70577..52dc6c188c 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact.Kind.get -> string? +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact.SessionFileArtifact(Microsoft.Testing.Platform.TestHost.SessionUid sessionUid, System.IO.FileInfo! fileInfo, string! displayName, string? description, string? kind) -> void [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities.IsStateful.get -> bool [TPEXP]Microsoft.Testing.Platform.Services.IClientInfo.Capabilities.get -> Microsoft.Testing.Platform.Services.IClientCapabilities! diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs index 96d007ed92..15498c2909 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs @@ -163,7 +163,8 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella artifact.Description ?? string.Empty, testNodeUpdateMessage.TestNode.Uid.Value, testNodeUpdateMessage.TestNode.DisplayName, - testNodeUpdateMessage.SessionUid.Value) + testNodeUpdateMessage.SessionUid.Value, + null) ]); await _dotnetTestConnection.SendMessageAsync(testFileArtifactMessages).ConfigureAwait(false); @@ -182,7 +183,8 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella sessionFileArtifact.Description ?? string.Empty, string.Empty, string.Empty, - sessionFileArtifact.SessionUid.Value) + sessionFileArtifact.SessionUid.Value, + sessionFileArtifact.Kind) ]); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); @@ -199,7 +201,8 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella fileArtifact.Description ?? string.Empty, string.Empty, string.Empty, - string.Empty) + string.Empty, + null) ]); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs index 4a0f5a52da..5db06f42ff 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Models/FileArtifactMessages.cs @@ -3,6 +3,6 @@ namespace Microsoft.Testing.Platform.IPC.Models; -internal sealed record FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid); +internal sealed record FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid, string? Kind); internal sealed record FileArtifactMessages(string? ExecutionId, string? InstanceId, FileArtifactMessage[] FileArtifacts) : IRequest; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs index 631a17e14d..16a6a61d43 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/ObjectFieldIds.cs @@ -142,6 +142,7 @@ internal static class FileArtifactMessageFieldsId public const ushort TestUid = 4; public const ushort TestDisplayName = 5; public const ushort SessionUid = 6; + public const ushort Kind = 7; } [Embedded] diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs index e946067ff0..7550ff5fbf 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs @@ -89,7 +89,7 @@ private static FileArtifactMessage[] ReadFileArtifactMessagesPayload(Stream stre for (int i = 0; i < length; i++) { - string? fullPath = null, displayName = null, description = null, testUid = null, testDisplayName = null, sessionUid = null; + string? fullPath = null, displayName = null, description = null, testUid = null, testDisplayName = null, sessionUid = null, kind = null; ReadFields(stream, (fieldId, fieldSize) => { @@ -119,12 +119,16 @@ private static FileArtifactMessage[] ReadFileArtifactMessagesPayload(Stream stre sessionUid = ReadStringValue(stream, fieldSize); return true; + case FileArtifactMessageFieldsId.Kind: + kind = ReadStringValue(stream, fieldSize); + return true; + default: return false; } }); - fileArtifactMessages[i] = new FileArtifactMessage(fullPath, displayName, description, testUid, testDisplayName, sessionUid); + fileArtifactMessages[i] = new FileArtifactMessage(fullPath, displayName, description, testUid, testDisplayName, sessionUid, kind); } return fileArtifactMessages; @@ -152,6 +156,7 @@ private static void WriteFileArtifactMessagesPayload(Stream stream, FileArtifact WriteField(s, FileArtifactMessageFieldsId.TestUid, fileArtifactMessage.TestUid); WriteField(s, FileArtifactMessageFieldsId.TestDisplayName, fileArtifactMessage.TestDisplayName); WriteField(s, FileArtifactMessageFieldsId.SessionUid, fileArtifactMessage.SessionUid); + WriteField(s, FileArtifactMessageFieldsId.Kind, fileArtifactMessage.Kind); }); private static ushort GetFieldCount(FileArtifactMessages fileArtifactMessages) => @@ -165,5 +170,6 @@ private static ushort GetFieldCount(FileArtifactMessage fileArtifactMessage) => (fileArtifactMessage.Description is null ? 0 : 1) + (fileArtifactMessage.TestUid is null ? 0 : 1) + (fileArtifactMessage.TestDisplayName is null ? 0 : 1) + - (fileArtifactMessage.SessionUid is null ? 0 : 1)); + (fileArtifactMessage.SessionUid is null ? 0 : 1) + + (fileArtifactMessage.Kind is null ? 0 : 1)); } diff --git a/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs b/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs index f071fabff7..8db951b64b 100644 --- a/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs +++ b/src/Platform/SharedExtensionHelpers/ReportGeneratorBase.cs @@ -174,7 +174,8 @@ await _messageBus.PublishAsync( testSessionContext.SessionUid, new FileInfo(reportFileName), ArtifactDisplayName, - ArtifactDescription)).ConfigureAwait(false); + ArtifactDescription, + ArtifactKind)).ConfigureAwait(false); } // Capture every update unconditionally — no UID-based deduplication. @@ -195,6 +196,14 @@ protected virtual void OnTestNodeUpdate(TestNodeUpdateMessage update) protected abstract string ArtifactDescription { get; } + /// + /// Gets the producer-asserted, reverse-DNS identifier of the artifact format this report + /// generator produces (e.g. microsoft.testing.junit). Used by post-processing to + /// group same-kind artifacts for consolidation. Returns by default + /// (no declared kind); report generators override to tag their output. + /// + protected virtual string? ArtifactKind => null; + protected abstract string GetGenerationLogMessage(int testResultCount); protected abstract TCapturedTestResult? TryCapture(TestNodeUpdateMessage update); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs new file mode 100644 index 0000000000..c601d1d53c --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -0,0 +1,208 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using System.Text.Json.Nodes; + +using Microsoft.Testing.Extensions.CtrfReport; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class CtrfReportMergerTests +{ + [TestMethod] + public void Merge_WithNullReports_ThrowsArgumentNullException() + => Assert.ThrowsExactly(() => CtrfReportMerger.Merge(null!)); + + [TestMethod] + public void Merge_WithNoReports_ThrowsArgumentException() + => Assert.ThrowsExactly(() => CtrfReportMerger.Merge([])); + + [TestMethod] + public void Merge_ConcatenatesTests() + { + string a = BuildReport(testEntries: [Test("TestA", "passed"), Test("TestB", "failed")]); + string b = BuildReport(testEntries: [Test("TestC", "passed")]); + + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!; + + var testArray = (JsonArray)merged["results"]!["tests"]!; + Assert.HasCount(3, testArray); + List names = [.. testArray.Select(t => (string?)t!["name"])]; + Assert.Contains("TestA", names); + Assert.Contains("TestC", names); + } + + [TestMethod] + public void Merge_DerivesSummaryCountersFromTests() + { + string a = BuildReport(testEntries: [Test("a", "passed"), Test("b", "passed"), Test("c", "failed")]); + string b = BuildReport(testEntries: [Test("d", "passed"), Test("e", "skipped"), Test("f", "skipped"), Test("g", "skipped")]); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["summary"]!; + + Assert.AreEqual(7, (long)summary["tests"]!); + Assert.AreEqual(3, (long)summary["passed"]!); + Assert.AreEqual(1, (long)summary["failed"]!); + Assert.AreEqual(3, (long)summary["skipped"]!); + } + + [TestMethod] + public void Merge_DerivesSummaryFromTests_WhenInputSummaryMissing() + { + // An input that carries tests[] but no summary object must still contribute to the merged counts. + string withSummary = BuildReport(testEntries: [Test("a", "passed")]); + string withoutSummary = BuildReportWithoutSummary(Test("b", "failed"), Test("c", "skipped")); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([withSummary, withoutSummary]))!["results"]!["summary"]!; + + Assert.AreEqual(3, (long)summary["tests"]!); + Assert.AreEqual(1, (long)summary["passed"]!); + Assert.AreEqual(1, (long)summary["failed"]!); + Assert.AreEqual(1, (long)summary["skipped"]!); + } + + [TestMethod] + public void Merge_SummaryStartIsEarliestAndStopIsLatest() + { + string a = BuildReport(start: 2000, stop: 3000); + string b = BuildReport(start: 1000, stop: 5000); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["summary"]!; + + Assert.AreEqual(1000, (long)summary["start"]!); + Assert.AreEqual(5000, (long)summary["stop"]!); + Assert.AreEqual(4000, (long)summary["duration"]!); + } + + [TestMethod] + public void Merge_KeepsReportFormatAndToolFromFirstReport() + { + string a = BuildReport(toolName: "MSTest"); + string b = BuildReport(toolName: "OtherFramework"); + + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!; + + Assert.AreEqual("CTRF", (string?)merged["reportFormat"]); + Assert.AreEqual("MSTest", (string?)merged["results"]!["tool"]!["name"]); + } + + [TestMethod] + public void Merge_GeneratesFreshReportId() + { + string a = BuildReport(); + string b = BuildReport(); + + string? idA = (string?)JsonNode.Parse(a)!["reportId"]; + string? mergedId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; + + Assert.IsNotNull(mergedId); + Assert.AreNotEqual(idA, mergedId); + } + + [TestMethod] + public async Task MergeToFileAsync_WritesMergedFileToDisk() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string first = Path.Combine(tempDirectory, "a.json"); + string second = Path.Combine(tempDirectory, "b.json"); + string output = Path.Combine(tempDirectory, "nested", "merged.json"); + File.WriteAllText(first, BuildReport(testEntries: [Test("a", "passed"), Test("b", "passed")])); + File.WriteAllText(second, BuildReport(testEntries: [Test("c", "passed"), Test("d", "passed"), Test("e", "passed")])); + + await CtrfReportMerger.MergeToFileAsync([first, second], output, CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + JsonNode merged = JsonNode.Parse(File.ReadAllText(output))!; + Assert.AreEqual(5, (long)merged["results"]!["summary"]!["tests"]!); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static JsonObject Test(string name, string status) + => new() + { + ["name"] = name, + ["status"] = status, + ["duration"] = 1, + }; + + private static string BuildReport( + long tests = 1, + long passed = 1, + long failed = 0, + long skipped = 0, + long pending = 0, + long other = 0, + long flaky = 0, + long start = 1000, + long stop = 2000, + string toolName = "MSTest", + IEnumerable? testEntries = null) + { + var testArray = new JsonArray(); + foreach (JsonObject test in testEntries ?? [Test("DefaultTest", "passed")]) + { + testArray.Add(test); + } + + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stop).ToString("O", CultureInfo.InvariantCulture), + ["generatedBy"] = "Microsoft.Testing.Extensions.CtrfReport", + ["results"] = new JsonObject + { + ["tool"] = new JsonObject { ["name"] = toolName }, + ["summary"] = new JsonObject + { + ["tests"] = tests, + ["passed"] = passed, + ["failed"] = failed, + ["skipped"] = skipped, + ["pending"] = pending, + ["other"] = other, + ["flaky"] = flaky, + ["start"] = start, + ["stop"] = stop, + ["duration"] = Math.Max(0, stop - start), + }, + ["environment"] = new JsonObject { ["osPlatform"] = "test" }, + ["tests"] = testArray, + }, + }; + + return report.ToJsonString(); + } + + private static string BuildReportWithoutSummary(params JsonObject[] testEntries) + { + var testArray = new JsonArray(); + foreach (JsonObject test in testEntries) + { + testArray.Add(test); + } + + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["results"] = new JsonObject + { + ["tool"] = new JsonObject { ["name"] = "MSTest" }, + ["tests"] = testArray, + }, + }; + + return report.ToJsonString(); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs new file mode 100644 index 0000000000..59913fdc99 --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs @@ -0,0 +1,167 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.JUnitReport; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class JUnitReportMergerTests +{ + [TestMethod] + public void Merge_WithNullReports_ThrowsArgumentNullException() + => Assert.ThrowsExactly(() => JUnitReportMerger.Merge(null!, "run")); + + [TestMethod] + public void Merge_WithNoReports_ThrowsArgumentException() + => Assert.ThrowsExactly(() => JUnitReportMerger.Merge([], "run")); + + [TestMethod] + public void Merge_SumsRootCounters() + { + XDocument a = BuildReport(tests: 3, failures: 1, errors: 0, skipped: 1, time: 1.5); + XDocument b = BuildReport(tests: 4, failures: 0, errors: 2, skipped: 0, time: 2.25); + + XElement root = JUnitReportMerger.Merge([a, b], "run").Root!; + + Assert.AreEqual("7", root.Attribute("tests")!.Value); + Assert.AreEqual("1", root.Attribute("failures")!.Value); + Assert.AreEqual("2", root.Attribute("errors")!.Value); + Assert.AreEqual("1", root.Attribute("skipped")!.Value); + Assert.AreEqual("3.750", root.Attribute("time")!.Value); + } + + [TestMethod] + public void Merge_UnionsSuitesAndReassignsSequentialIds() + { + XDocument a = BuildReport(suites: [Suite("SuiteA"), Suite("SuiteB")]); + XDocument b = BuildReport(suites: [Suite("SuiteC")]); + + XElement root = JUnitReportMerger.Merge([a, b], "run").Root!; + + List suites = [.. root.Elements().Where(e => e.Name.LocalName == "testsuite")]; + Assert.HasCount(3, suites); + List ids = [.. suites.Select(s => s.Attribute("id")!.Value)]; + Assert.AreSequenceEqual(new[] { "0", "1", "2" }, ids); + List names = [.. suites.Select(s => s.Attribute("name")!.Value)]; + Assert.Contains("SuiteA", names); + Assert.Contains("SuiteC", names); + } + + [TestMethod] + public void Merge_SetsProvidedReportName() + { + XElement root = JUnitReportMerger.Merge([BuildReport(), BuildReport()], "my-merged-run").Root!; + + Assert.AreEqual("testsuites", root.Name.LocalName); + Assert.AreEqual("my-merged-run", root.Attribute("name")!.Value); + } + + [TestMethod] + public void Merge_TimestampIsEarliestAcrossInputs() + { + XDocument a = BuildReport(timestamp: new DateTimeOffset(2020, 1, 1, 12, 0, 0, TimeSpan.Zero)); + XDocument b = BuildReport(timestamp: new DateTimeOffset(2020, 1, 1, 9, 0, 0, TimeSpan.Zero)); + + XElement root = JUnitReportMerger.Merge([a, b], "run").Root!; + + Assert.AreEqual("2020-01-01T09:00:00.000", root.Attribute("timestamp")!.Value); + } + + [TestMethod] + public void Merge_SupportsBareTestSuiteRootedInput() + { + // A document rooted at a bare (rather than ) is a valid JUnit shape + // and must not be silently dropped. + var bareSuite = new XDocument(Suite("BareSuite", tests: 2, failures: 1)); + XDocument normal = BuildReport(tests: 3); + + XElement root = JUnitReportMerger.Merge([bareSuite, normal], "run").Root!; + + Assert.HasCount(2, root.Elements().Where(e => e.Name.LocalName == "testsuite")); + Assert.AreEqual("5", root.Attribute("tests")!.Value); + Assert.AreEqual("1", root.Attribute("failures")!.Value); + } + + [TestMethod] + public void Merge_DerivesCountersFromSuitesWhenRootAggregatesMissing() + { + // Root carries no aggregate attributes; totals must still come from the child suites. + var rootWithoutAggregates = new XDocument(new XElement("testsuites", new XAttribute("name", "asm"), Suite("S1", tests: 4, skipped: 2))); + XDocument normal = BuildReport(tests: 1); + + XElement root = JUnitReportMerger.Merge([rootWithoutAggregates, normal], "run").Root!; + + Assert.AreEqual("5", root.Attribute("tests")!.Value); + Assert.AreEqual("2", root.Attribute("skipped")!.Value); + } + + [TestMethod] + public async Task MergeToFileAsync_WritesMergedFileToDisk() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string first = Path.Combine(tempDirectory, "a.xml"); + string second = Path.Combine(tempDirectory, "b.xml"); + string output = Path.Combine(tempDirectory, "nested", "merged.xml"); + BuildReport(tests: 2).Save(first); + BuildReport(tests: 3).Save(second); + + await JUnitReportMerger.MergeToFileAsync([first, second], output, "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + var merged = XDocument.Load(output); + Assert.AreEqual("5", merged.Root!.Attribute("tests")!.Value); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static XElement Suite( + string name, + long tests = 1, + long failures = 0, + long errors = 0, + long skipped = 0, + double time = 0, + DateTimeOffset? timestamp = null) + => new( + "testsuite", + new XAttribute("name", name), + new XAttribute("tests", tests), + new XAttribute("failures", failures), + new XAttribute("errors", errors), + new XAttribute("skipped", skipped), + new XAttribute("time", time.ToString("0.000", CultureInfo.InvariantCulture)), + new XAttribute("timestamp", (timestamp ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)), + new XElement("testcase", new XAttribute("name", $"{name}.Test1"), new XAttribute("classname", name))); + + private static XDocument BuildReport( + long tests = 1, + long failures = 0, + long errors = 0, + long skipped = 0, + double time = 0, + DateTimeOffset? timestamp = null, + IEnumerable? suites = null) + { + // The merged aggregates are derived from the per-suite counters, so the default suite carries + // the report's counters/timestamp (the root aggregates are illustrative only). + var root = new XElement( + "testsuites", + new XAttribute("name", "assembly"), + new XAttribute("tests", tests), + new XAttribute("failures", failures), + new XAttribute("errors", errors), + new XAttribute("skipped", skipped), + new XAttribute("time", time.ToString("0.000", CultureInfo.InvariantCulture)), + new XAttribute("timestamp", (timestamp ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero)).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fff", CultureInfo.InvariantCulture)), + suites ?? [Suite("DefaultSuite", tests, failures, errors, skipped, time, timestamp)]); + + return new XDocument(root); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs new file mode 100644 index 0000000000..5467cd4f5f --- /dev/null +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -0,0 +1,264 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Extensions.TrxReport.Abstractions; + +namespace Microsoft.Testing.Extensions.UnitTests; + +[TestClass] +public sealed class TrxReportEngineMergeTests +{ + private static readonly XNamespace Ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"; + + [TestMethod] + public void Merge_WithNullReports_ThrowsArgumentNullException() + => Assert.ThrowsExactly(() => TrxReportEngine.Merge(null!, Guid.NewGuid(), "run")); + + [TestMethod] + public void Merge_WithNoReports_ThrowsArgumentException() + => Assert.ThrowsExactly(() => TrxReportEngine.Merge([], Guid.NewGuid(), "run")); + + [TestMethod] + public void Merge_SetsProvidedRunIdAndName() + { + var runId = Guid.NewGuid(); + + XDocument merged = TrxReportEngine.Merge([BuildReport(), BuildReport()], runId, "my-merged-run"); + + XElement testRun = merged.Root!; + Assert.AreEqual("TestRun", testRun.Name.LocalName); + Assert.AreEqual(runId.ToString(), testRun.Attribute("id")!.Value); + Assert.AreEqual("my-merged-run", testRun.Attribute("name")!.Value); + } + + [TestMethod] + public void Merge_SumsCounters() + { + XDocument a = BuildReport(total: 3, passed: 2, failed: 1, notExecuted: 0, timeout: 0); + XDocument b = BuildReport(total: 4, passed: 1, failed: 0, notExecuted: 3, timeout: 0); + + XElement counters = Counters(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + Assert.AreEqual("7", counters.Attribute("total")!.Value); + Assert.AreEqual("3", counters.Attribute("passed")!.Value); + Assert.AreEqual("1", counters.Attribute("failed")!.Value); + Assert.AreEqual("3", counters.Attribute("notExecuted")!.Value); + } + + [TestMethod] + public void Merge_WithAllCompletedReports_OutcomeIsCompleted() + { + XDocument merged = TrxReportEngine.Merge( + [BuildReport(outcome: "Completed"), BuildReport(outcome: "Completed")], + Guid.NewGuid(), + "run"); + + Assert.AreEqual("Completed", ResultSummary(merged).Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_WhenAnyReportFailed_OutcomeIsFailed() + { + XDocument merged = TrxReportEngine.Merge( + [BuildReport(outcome: "Completed"), BuildReport(outcome: "Failed")], + Guid.NewGuid(), + "run"); + + Assert.AreEqual("Failed", ResultSummary(merged).Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_WhenFailedCounterIsPositive_OutcomeIsFailed() + { + // Both reports claim "Completed" in the summary, but one has a positive failed counter. + XDocument merged = TrxReportEngine.Merge( + [BuildReport(outcome: "Completed", total: 1, passed: 1), BuildReport(outcome: "Completed", total: 1, passed: 0, failed: 1)], + Guid.NewGuid(), + "run"); + + Assert.AreEqual("Failed", ResultSummary(merged).Attribute("outcome")!.Value); + } + + [TestMethod] + public void Merge_UnionsResults() + { + XDocument a = BuildReport(results: [Result("e1", "t1", "TestA")]); + XDocument b = BuildReport(results: [Result("e2", "t2", "TestB"), Result("e3", "t3", "TestC")]); + + XElement results = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Results"); + + List merged = [.. results.Elements()]; + Assert.HasCount(3, merged); + List testNames = [.. merged.Select(e => e.Attribute("testName")!.Value)]; + Assert.Contains("TestA", testNames); + Assert.Contains("TestC", testNames); + } + + [TestMethod] + public void Merge_UnionsTestDefinitionsAndEntries() + { + XElement def = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("name", "TestA")); + XElement entry = new(Ns + "TestEntry", new XAttribute("testId", "t1"), new XAttribute("executionId", "e1")); + XDocument a = BuildReport(testDefinitions: [def], testEntries: [entry]); + XDocument b = BuildReport( + testDefinitions: [new XElement(Ns + "UnitTest", new XAttribute("id", "t2"), new XAttribute("name", "TestB"))], + testEntries: [new XElement(Ns + "TestEntry", new XAttribute("testId", "t2"), new XAttribute("executionId", "e2"))]); + + XElement root = TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!; + + Assert.HasCount(2, Child(root, "TestDefinitions").Elements()); + Assert.HasCount(2, Child(root, "TestEntries").Elements()); + } + + [TestMethod] + public void Merge_DeduplicatesTestListsById() + { + // Both reports carry the two well-known shared test lists; the merged output keeps each id once. + XDocument a = BuildReport(testLists: DefaultTestLists()); + XDocument b = BuildReport(testLists: [.. DefaultTestLists(), TestList("11111111-1111-1111-1111-111111111111", "Extra")]); + + XElement testLists = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "TestLists"); + + List ids = [.. testLists.Elements().Select(e => e.Attribute("id")!.Value)]; + Assert.HasCount(3, ids); + Assert.HasCount(3, ids.Distinct()); + } + + [TestMethod] + public void Merge_TimesUseEarliestStartAndLatestFinish() + { + var earlyStart = new DateTimeOffset(2020, 1, 1, 9, 0, 0, TimeSpan.Zero); + var lateFinish = new DateTimeOffset(2020, 1, 1, 13, 0, 0, TimeSpan.Zero); + XDocument a = BuildReport(start: new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero), finish: new DateTimeOffset(2020, 1, 1, 11, 0, 0, TimeSpan.Zero)); + XDocument b = BuildReport(start: earlyStart, finish: lateFinish); + + XElement times = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Times"); + + Assert.AreEqual(earlyStart, DateTimeOffset.Parse(times.Attribute("start")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(lateFinish, DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + } + + [TestMethod] + public void Merge_PreservesResultFileAttachmentPaths() + { + string attachmentPath = Path.Combine(Path.GetTempPath(), "results-a", "log.txt"); + XElement resultWithAttachment = new( + Ns + "UnitTestResult", + new XAttribute("executionId", "e1"), + new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", attachmentPath)))); + + XElement results = Child(TrxReportEngine.Merge([BuildReport(results: [resultWithAttachment]), BuildReport()], Guid.NewGuid(), "run").Root!, "Results"); + + XElement? resultFile = results.Descendants(Ns + "ResultFile").FirstOrDefault(); + Assert.IsNotNull(resultFile); + Assert.AreEqual(attachmentPath, resultFile.Attribute("path")!.Value); + } + + [TestMethod] + public void Merge_WithSingleReport_KeepsAllResults() + { + XDocument single = BuildReport(total: 2, passed: 2, results: [Result("e1", "t1", "TestA"), Result("e2", "t2", "TestB")]); + + XElement root = TrxReportEngine.Merge([single], Guid.NewGuid(), "run").Root!; + + Assert.HasCount(2, Child(root, "Results").Elements()); + Assert.AreEqual("2", Counters(root.Document!).Attribute("total")!.Value); + } + + [TestMethod] + public async Task MergeToFileAsync_WritesMergedFileToDisk() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string first = Path.Combine(tempDirectory, "a.trx"); + string second = Path.Combine(tempDirectory, "b.trx"); + string output = Path.Combine(tempDirectory, "nested", "merged.trx"); + BuildReport(total: 2, passed: 2).Save(first); + BuildReport(total: 3, passed: 3).Save(second); + + await TrxReportEngine.MergeToFileAsync([first, second], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + var merged = XDocument.Load(output); + Assert.AreEqual("5", Counters(merged).Attribute("total")!.Value); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static XElement Child(XElement parent, string localName) + => parent.Elements().First(e => e.Name.LocalName == localName); + + private static XElement ResultSummary(XDocument document) + => Child(document.Root!, "ResultSummary"); + + private static XElement Counters(XDocument document) + => Child(ResultSummary(document), "Counters"); + + private static XElement Result(string executionId, string testId, string testName) + => new( + Ns + "UnitTestResult", + new XAttribute("executionId", executionId), + new XAttribute("testId", testId), + new XAttribute("testName", testName)); + + private static XElement TestList(string id, string name) + => new(Ns + "TestList", new XAttribute("id", id), new XAttribute("name", name)); + + private static XElement[] DefaultTestLists() + => + [ + TestList("8C84FA94-04C1-424b-9868-57A2D4851A1D", "Results Not in a List"), + TestList("19431567-8539-422a-85D7-44EE4E166BDA", "All Loaded Results"), + ]; + + private static XDocument BuildReport( + string outcome = "Completed", + int total = 1, + int passed = 1, + int failed = 0, + int notExecuted = 0, + int timeout = 0, + DateTimeOffset? start = null, + DateTimeOffset? finish = null, + IEnumerable? results = null, + IEnumerable? testDefinitions = null, + IEnumerable? testEntries = null, + IEnumerable? testLists = null) + { + DateTimeOffset startTime = start ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero); + DateTimeOffset finishTime = finish ?? new DateTimeOffset(2020, 1, 1, 11, 0, 0, TimeSpan.Zero); + + var testRun = new XElement( + Ns + "TestRun", + new XAttribute("id", Guid.NewGuid()), + new XAttribute("name", "run"), + new XElement( + Ns + "Times", + new XAttribute("creation", startTime), + new XAttribute("queuing", startTime), + new XAttribute("start", startTime), + new XAttribute("finish", finishTime)), + new XElement(Ns + "Results", results ?? []), + new XElement(Ns + "TestDefinitions", testDefinitions ?? []), + new XElement(Ns + "TestEntries", testEntries ?? []), + new XElement(Ns + "TestLists", testLists ?? DefaultTestLists()), + new XElement( + Ns + "ResultSummary", + new XAttribute("outcome", outcome), + new XElement( + Ns + "Counters", + new XAttribute("total", total), + new XAttribute("executed", passed + failed), + new XAttribute("passed", passed), + new XAttribute("failed", failed), + new XAttribute("timeout", timeout), + new XAttribute("notExecuted", notExecuted)))); + + return new XDocument(testRun); + } +} diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs index 1b62e1765e..501106ad85 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs @@ -211,8 +211,8 @@ public void FileArtifactMessages_RoundTrips() "executionId", "instanceId", [ - new FileArtifactMessage("full/path.txt", "artifact", "desc", "testUid", "testDisplay", "sessionUid"), - new FileArtifactMessage("other.txt", "other", null, null, null, null), + new FileArtifactMessage("full/path.txt", "artifact", "desc", "testUid", "testDisplay", "sessionUid", "microsoft.testing.trx"), + new FileArtifactMessage("other.txt", "other", null, null, null, null, null), ]); FileArtifactMessages actual = RoundTrip(new FileArtifactMessagesSerializer(), message); @@ -221,8 +221,10 @@ public void FileArtifactMessages_RoundTrips() Assert.HasCount(2, actual.FileArtifacts); Assert.AreEqual("full/path.txt", actual.FileArtifacts[0].FullPath); Assert.AreEqual("sessionUid", actual.FileArtifacts[0].SessionUid); + Assert.AreEqual("microsoft.testing.trx", actual.FileArtifacts[0].Kind); Assert.AreEqual("other.txt", actual.FileArtifacts[1].FullPath); Assert.IsNull(actual.FileArtifacts[1].Description); + Assert.IsNull(actual.FileArtifacts[1].Kind); } [TestMethod] diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs index 612165bf92..6da0ada882 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolEdgeCaseTests.cs @@ -125,9 +125,9 @@ public void FileArtifactMessages_WhenMultipleArtifactsWithMixedNulls_RoundTrips( "exec", "inst", [ - new FileArtifactMessage("/a/b.trx", "b.trx", "a trx", "uid", "Test", "session"), - new FileArtifactMessage("/c/d.coverage", "d.coverage", null, null, null, null), - new FileArtifactMessage("/e/f.txt", null, null, "uid2", null, "session2"), + new FileArtifactMessage("/a/b.trx", "b.trx", "a trx", "uid", "Test", "session", "microsoft.testing.trx"), + new FileArtifactMessage("/c/d.coverage", "d.coverage", null, null, null, null, null), + new FileArtifactMessage("/e/f.txt", null, null, "uid2", null, "session2", null), ]); FileArtifactMessages actual = RoundTrip(new FileArtifactMessagesSerializer(), message); @@ -136,6 +136,8 @@ public void FileArtifactMessages_WhenMultipleArtifactsWithMixedNulls_RoundTrips( Assert.AreEqual("inst", actual.InstanceId); Assert.AreEqual("/c/d.coverage", actual.FileArtifacts[1].FullPath); Assert.IsNull(actual.FileArtifacts[1].Description); + Assert.AreEqual("microsoft.testing.trx", actual.FileArtifacts[0].Kind); + Assert.IsNull(actual.FileArtifacts[1].Kind); Assert.AreEqual("session2", actual.FileArtifacts[2].SessionUid); Assert.IsNull(actual.FileArtifacts[2].DisplayName); } diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs index 602d69b16b..13a0790807 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs @@ -383,8 +383,8 @@ public void FileArtifactMessagesSerializeDeserialize() "MyExecId", "MyInstId", [ - new FileArtifactMessage("/full/path/artifact1.txt", "artifact1", "description1", "uid-1", "Test 1", "session-1"), - new FileArtifactMessage("/full/path/artifact2.coverage", "artifact2", null, null, null, null), + new FileArtifactMessage("/full/path/artifact1.txt", "artifact1", "description1", "uid-1", "Test 1", "session-1", "microsoft.testing.trx"), + new FileArtifactMessage("/full/path/artifact2.coverage", "artifact2", null, null, null, null, null), ]); var stream = new MemoryStream(); From 3d61cd424a2367314a2867697c17e311cb8cc8c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 13 Jul 2026 17:04:28 +0200 Subject: [PATCH 02/26] Address review: TRX merge fidelity + out-of-process Kind - Carry microsoft.testing.trx through the out-of-process TRX path by adding an (experimental) Kind to FileArtifact and propagating it in DotnetTestDataConsumer and TrxProcessLifetimeHandler; centralize the kind in TrxReportEngine.TrxArtifactKind. - Deduplicate definitions by id when merging TRX (ids are deterministic from the test UID and the schema forbids duplicates). - Preserve (crash/exit diagnostics) and from every input's instead of discarding them; omit them when empty. - Correct the inaccurate 'attachment paths are absolute' remark and relocate each input's attachment deployment tree into the merged deployment root in MergeToFileAsync (best-effort, never fails the merge). - Add unit tests for dedup and diagnostics preservation. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../InternalAPI/InternalAPI.Unshipped.txt | 1 + .../TrxDataConsumer.cs | 4 +- .../TrxProcessLifetimeHandler.cs | 7 +- .../TrxReportEngine.Merge.cs | 126 +++++++++++++++++- .../TrxReportEngine.cs | 6 + .../Messages/FileArtifacts.cs | 34 +++++ .../PublicAPI/PublicAPI.Unshipped.txt | 2 + .../DotnetTest/IPC/DotnetTestDataConsumer.cs | 2 +- .../TrxReportEngineMergeTests.cs | 95 +++++++++++-- 9 files changed, 253 insertions(+), 24 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt index 404ca6bc48..9fe4310d4e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt @@ -1,4 +1,5 @@ #nullable enable +const Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.TrxArtifactKind = "microsoft.testing.trx" -> string! const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort static Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.Merge(System.Collections.Generic.IReadOnlyList! inputReports, System.Guid runId, string! runName) -> System.Xml.Linq.XDocument! static Microsoft.Testing.Extensions.TrxReport.Abstractions.TrxReportEngine.MergeToFileAsync(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath, System.Guid runId, string! runName, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs index a822e19950..33ed42fdef 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxDataConsumer.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Extensions.TrxReport.Abstractions.Serializers; @@ -251,7 +251,7 @@ await _outputDisplay.DisplayAsync( // If we are running with out-of-process mode, we communicate via pipe to the TestHostController and send the ReportFileNameRequest. if (!TrxModeHelpers.ShouldUseOutOfProcessTrxGeneration(_commandLineOptionsService)) { - await _messageBus.PublishAsync(this, new SessionFileArtifact(testSessionContext.SessionUid, new FileInfo(reportFileName), ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription, "microsoft.testing.trx")).ConfigureAwait(false); + await _messageBus.PublishAsync(this, new SessionFileArtifact(testSessionContext.SessionUid, new FileInfo(reportFileName), ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription, TrxReportEngine.TrxArtifactKind)).ConfigureAwait(false); } else { diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs index 3458995d97..24c28ce4be 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxProcessLifetimeHandler.cs @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using Microsoft.Testing.Extensions.TrxReport.Abstractions.Serializers; @@ -224,7 +224,8 @@ await _messageBus.PublishAsync( new FileArtifact( new FileInfo(fileName), ExtensionResources.TrxReportArtifactDisplayName, - ExtensionResources.TrxReportArtifactDescription)).ConfigureAwait(false); + ExtensionResources.TrxReportArtifactDescription, + TrxReportEngine.TrxArtifactKind)).ConfigureAwait(false); TryDeleteStreamingSidecar(); return; @@ -260,7 +261,7 @@ await _messageBus.PublishAsync( await trxReportGeneratorEngine.AddArtifactsAsync(trxFile, artifacts).ConfigureAwait(false); } - await _messageBus.PublishAsync(this, new FileArtifact(trxFile, ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription)).ConfigureAwait(false); + await _messageBus.PublishAsync(this, new FileArtifact(trxFile, ExtensionResources.TrxReportArtifactDisplayName, ExtensionResources.TrxReportArtifactDescription, TrxReportEngine.TrxArtifactKind)).ConfigureAwait(false); // Best-effort orphan cleanup. On the happy path the test host normally deletes its own // sidecar in TrxReportGenerator.GenerateReportAndCleanupAsync, but if its CompleteAsync timed diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 6a39eac726..7a9ec6d8ef 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -18,11 +18,12 @@ internal sealed partial class TrxReportEngine /// /// Merge rules: /// - /// Results, TestDefinitions and TestEntries are unioned as-is. + /// Results and TestEntries are unioned as-is; TestDefinitions are deduplicated by id (ids are derived deterministically from each test's UID and the schema forbids duplicates). /// TestLists are deduplicated by id (the well-known lists are shared across files). /// Counters attributes are summed; Times use the earliest start and latest finish. + /// RunInfos (crash/exit diagnostics) and CollectorDataEntries (attachment references) are carried across from every input's ResultSummary. /// The result summary outcome is Failed if any input failed, otherwise Completed. - /// Attachment/result-file paths are preserved verbatim (they are absolute today). + /// Attachment hrefs inside CollectorDataEntries are carried as-is; because they are relative to each input's deployment root, the physical attachment files are only relocated to the merged deployment root by (which has the source paths). Callers of the in-memory that need resolvable attachments should relocate them separately. /// /// /// @@ -49,6 +50,18 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI var mergedTestLists = new XElement(NamespaceUri + "TestLists"); var seenTestListIds = new HashSet(StringComparer.OrdinalIgnoreCase); + // TestDefinition ids are derived deterministically from each test's UID, so the same test + // discovered in more than one input yields the same id. The TRX schema (and the producer, + // see TrxReportEngine.Results.cs) does not allow duplicate , so we keep + // only the first definition seen per id. + var seenTestDefinitionIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + // Run-level diagnostics () and collector attachments () live + // under ; carry them across so merged reports don't silently lose crash/exit + // messages and attachment references. + var mergedRunInfos = new XElement(NamespaceUri + "RunInfos"); + var mergedCollectorDataEntries = new XElement(NamespaceUri + "CollectorDataEntries"); + // Preserve the order in which counter attributes are first seen so the merged // element keeps the well-known TRX attribute ordering. var counterAttributeOrder = new List(); @@ -67,7 +80,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI } CloneChildrenInto(FindChild(testRun, "Results"), mergedResults); - CloneChildrenInto(FindChild(testRun, "TestDefinitions"), mergedTestDefinitions); + CloneChildrenIntoDeduplicatedById(FindChild(testRun, "TestDefinitions"), mergedTestDefinitions, seenTestDefinitionIds); CloneChildrenInto(FindChild(testRun, "TestEntries"), mergedTestEntries); XElement? testLists = FindChild(testRun, "TestLists"); @@ -92,6 +105,8 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI } AccumulateCounters(FindChild(resultSummary, "Counters"), counterAttributeOrder, counterSums); + CloneChildrenInto(FindChild(resultSummary, "RunInfos"), mergedRunInfos); + CloneChildrenInto(FindChild(resultSummary, "CollectorDataEntries"), mergedCollectorDataEntries); } XElement? times = FindChild(testRun, "Times"); @@ -132,7 +147,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI mergedTestRun.Add(mergedTestDefinitions); mergedTestRun.Add(mergedTestEntries); mergedTestRun.Add(mergedTestLists); - mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums)); + mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums, mergedRunInfos, mergedCollectorDataEntries)); return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedTestRun); } @@ -172,6 +187,13 @@ internal static async Task MergeToFileAsync( Directory.CreateDirectory(outputDirectory); } + // Attachment hrefs inside CollectorDataEntries are relative to each input's deployment root + // (they look like "/" and physically live under + // "//In/..."). Copy those trees into the merged report's + // deployment root so the carried-over hrefs resolve. Best-effort: failures to copy an + // attachment must not fail the merge. + RelocateAttachments(inputPaths, reports, outputDirectory ?? Directory.GetCurrentDirectory(), runName); + using FileStream stream = File.Create(outputPath); #if NETCOREAPP await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); @@ -184,6 +206,65 @@ internal static async Task MergeToFileAsync( private static XElement? FindChild(XElement parent, string localName) => parent.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, localName, StringComparison.Ordinal)); + /// + /// Copies each input report's attachment deployment tree (<deploymentRoot>/In) into the + /// merged report's deployment root, so the attachment hrefs carried over from + /// CollectorDataEntries keep resolving. Best-effort: any copy failure is swallowed so it + /// never fails the merge (matching the never-fail-the-run post-processing invariant). + /// + private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, string runName) + { + string mergedDeploymentRoot = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); + string mergedInRoot = Path.Combine(outputDirectory, mergedDeploymentRoot, "In"); + + for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) + { + try + { + string? inputDirectory = Path.GetDirectoryName(Path.GetFullPath(inputPaths[i])); + string? inputDeploymentRoot = reports[i].Root is { } root + ? FindChild(root, "TestSettings")?.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, "Deployment", StringComparison.Ordinal))?.Attribute("runDeploymentRoot")?.Value + : null; + + if (RoslynString.IsNullOrEmpty(inputDirectory) || RoslynString.IsNullOrEmpty(inputDeploymentRoot)) + { + continue; + } + + string sourceInRoot = Path.Combine(inputDirectory, inputDeploymentRoot, "In"); + if (Directory.Exists(sourceInRoot)) + { + CopyDirectoryRecursive(sourceInRoot, mergedInRoot); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort: a failed attachment copy must not fail the merge. + } + } + } + + private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory) + { + Directory.CreateDirectory(destinationDirectory); + + foreach (string file in Directory.GetFiles(sourceDirectory)) + { + // Do not overwrite: if two inputs contributed the same relative attachment path, keep the + // first one rather than silently clobbering it. + string destination = Path.Combine(destinationDirectory, Path.GetFileName(file)); + if (!File.Exists(destination)) + { + File.Copy(file, destination); + } + } + + foreach (string directory in Directory.GetDirectories(sourceDirectory)) + { + CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); + } + } + private static void CloneChildrenInto(XElement? source, XElement destination) { if (source is null) @@ -197,6 +278,25 @@ private static void CloneChildrenInto(XElement? source, XElement destination) } } + private static void CloneChildrenIntoDeduplicatedById(XElement? source, XElement destination, HashSet seenIds) + { + if (source is null) + { + return; + } + + foreach (XElement child in source.Elements()) + { + string? id = child.Attribute("id")?.Value; + + // Keep the first definition seen for a given id; a null/absent id is always kept. + if (id is null || seenIds.Add(id)) + { + destination.Add(new XElement(child)); + } + } + } + private static void AccumulateCounters(XElement? counters, List attributeOrder, Dictionary sums) { if (counters is null) @@ -249,7 +349,7 @@ private static XElement BuildTestSettings(string runName) return testSettings; } - private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums) + private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement runInfos, XElement collectorDataEntries) { var counters = new XElement(NamespaceUri + "Counters"); foreach (string name in counterAttributeOrder) @@ -257,10 +357,24 @@ private static XElement BuildResultSummary(string outcome, List counterA counters.SetAttributeValue(name, counterSums[name].ToString(CultureInfo.InvariantCulture)); } - return new XElement( + var resultSummary = new XElement( NamespaceUri + "ResultSummary", new XAttribute("outcome", outcome), counters); + + // Emit the diagnostics/attachment children only when they carry content, matching the shape + // the single-run producer writes (which omits empty /). + if (runInfos.HasElements) + { + resultSummary.Add(runInfos); + } + + if (collectorDataEntries.HasElements) + { + resultSummary.Add(collectorDataEntries); + } + + return resultSummary; } private static bool TryParseDateTimeOffset(string? value, out DateTimeOffset result) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs index d0d7aecd35..4d29ccdb09 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.cs @@ -16,6 +16,12 @@ namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; internal sealed partial class TrxReportEngine { + /// + /// The producer-asserted, reverse-DNS identifier of the TRX artifact format, used by + /// post-processing to group TRX reports for consolidation. + /// + internal const string TrxArtifactKind = "microsoft.testing.trx"; + private const string UnitTestTypeGuid = "13CDC9D9-DDB5-4fa4-A97D-D965CCFC6D4B"; private const string UncategorizedTestListId = "8C84FA94-04C1-424b-9868-57A2D4851A1D"; diff --git a/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs b/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs index c3bfc09b77..c1659e6dd6 100644 --- a/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs +++ b/src/Platform/Microsoft.Testing.Platform/Messages/FileArtifacts.cs @@ -16,14 +16,48 @@ public class FileArtifact : PropertyBagData /// The file information. /// The display name. /// The description. + // This shipped constructor intentionally keeps its optional 'description' parameter for binary + // compatibility; the newer overload below adds 'kind'. Folding them into a single constructor + // would binary-break existing compiled callers of this 3-parameter overload, so RS0027 (which + // wants the optional-parameter overload to have the most parameters) is suppressed here. +#pragma warning disable RS0027 // API with optional parameter(s) should have the most parameters amongst its public overloads public FileArtifact(FileInfo fileInfo, string displayName, string? description = null) +#pragma warning restore RS0027 : base(displayName, description) => FileInfo = fileInfo; + /// + /// Initializes a new instance of the class with a producer-asserted artifact kind. + /// + /// The file information. + /// The display name. + /// The description. + /// + /// An optional producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx). Used by post-processing to group artifacts of + /// the same kind for consolidation. when the producer does not + /// declare a kind. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public FileArtifact(FileInfo fileInfo, string displayName, string? description, string? kind) + : base(displayName, description) + { + FileInfo = fileInfo; + Kind = kind; + } + /// /// Gets the file information. /// public FileInfo FileInfo { get; } + /// + /// Gets the producer-asserted, reverse-DNS identifier of the artifact format + /// (e.g. microsoft.testing.trx), or when the producer + /// did not declare one. + /// + [Experimental("TPEXP", UrlFormat = "https://aka.ms/testingplatform/diagnostics#{0}")] + public string? Kind { get; } + /// public override string ToString() { diff --git a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt index 0af6a79bc1..6d0e031de1 100644 --- a/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/PublicAPI/PublicAPI.Unshipped.txt @@ -1,4 +1,6 @@ #nullable enable +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.FileArtifact.FileArtifact(System.IO.FileInfo! fileInfo, string! displayName, string? description, string? kind) -> void +[TPEXP]Microsoft.Testing.Platform.Extensions.Messages.FileArtifact.Kind.get -> string? [TPEXP]Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact.Kind.get -> string? [TPEXP]Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact.SessionFileArtifact(Microsoft.Testing.Platform.TestHost.SessionUid sessionUid, System.IO.FileInfo! fileInfo, string! displayName, string? description, string? kind) -> void [TPEXP]Microsoft.Testing.Platform.Services.IClientCapabilities diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs index 8de2769d11..3ae4e87537 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs @@ -204,7 +204,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella string.Empty, string.Empty, string.Empty, - null) + fileArtifact.Kind) ]); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 5467cd4f5f..5395518089 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -110,6 +110,69 @@ public void Merge_UnionsTestDefinitionsAndEntries() Assert.HasCount(2, Child(root, "TestEntries").Elements()); } + [TestMethod] + public void Merge_DeduplicatesTestDefinitionsById() + { + // The same test discovered in two inputs yields the same deterministic UnitTest id; the + // merged report must not emit duplicate . + XElement sharedDef = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("name", "SharedTest")); + XDocument a = BuildReport(testDefinitions: [new XElement(sharedDef)]); + XDocument b = BuildReport( + testDefinitions: + [ + new XElement(sharedDef), + new XElement(Ns + "UnitTest", new XAttribute("id", "t2"), new XAttribute("name", "OtherTest")), + ]); + + XElement definitions = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "TestDefinitions"); + + List ids = [.. definitions.Elements().Select(e => e.Attribute("id")!.Value)]; + Assert.HasCount(2, ids); + Assert.HasCount(2, ids.Distinct()); + } + + [TestMethod] + public void Merge_PreservesRunInfosFromInputs() + { + XElement runInfos = new( + Ns + "RunInfos", + new XElement(Ns + "RunInfo", new XAttribute("outcome", "Error"), new XElement(Ns + "Text", "host crashed"))); + XDocument a = BuildReport(resultSummaryChildren: [runInfos]); + XDocument b = BuildReport(); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + XElement? mergedRunInfos = summary.Elements().FirstOrDefault(e => e.Name.LocalName == "RunInfos"); + Assert.IsNotNull(mergedRunInfos); + Assert.Contains("host crashed", mergedRunInfos.Value); + } + + [TestMethod] + public void Merge_PreservesCollectorDataEntriesFromInputs() + { + XElement entries = new( + Ns + "CollectorDataEntries", + new XElement(Ns + "Collector", new XAttribute("collectorDisplayName", "Code Coverage"))); + XDocument a = BuildReport(resultSummaryChildren: [entries]); + XDocument b = BuildReport(); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + XElement? mergedEntries = summary.Elements().FirstOrDefault(e => e.Name.LocalName == "CollectorDataEntries"); + Assert.IsNotNull(mergedEntries); + Assert.HasCount(1, mergedEntries.Elements().Where(e => e.Name.LocalName == "Collector")); + } + + [TestMethod] + public void Merge_WhenNoDiagnostics_OmitsEmptyRunInfosAndCollectorDataEntries() + { + XElement summary = ResultSummary(TrxReportEngine.Merge([BuildReport(), BuildReport()], Guid.NewGuid(), "run")); + + List childNames = [.. summary.Elements().Select(e => e.Name.LocalName)]; + Assert.DoesNotContain("RunInfos", childNames); + Assert.DoesNotContain("CollectorDataEntries", childNames); + } + [TestMethod] public void Merge_DeduplicatesTestListsById() { @@ -228,11 +291,29 @@ private static XDocument BuildReport( IEnumerable? results = null, IEnumerable? testDefinitions = null, IEnumerable? testEntries = null, - IEnumerable? testLists = null) + IEnumerable? testLists = null, + IEnumerable? resultSummaryChildren = null) { DateTimeOffset startTime = start ?? new DateTimeOffset(2020, 1, 1, 10, 0, 0, TimeSpan.Zero); DateTimeOffset finishTime = finish ?? new DateTimeOffset(2020, 1, 1, 11, 0, 0, TimeSpan.Zero); + var resultSummary = new XElement( + Ns + "ResultSummary", + new XAttribute("outcome", outcome), + new XElement( + Ns + "Counters", + new XAttribute("total", total), + new XAttribute("executed", passed + failed), + new XAttribute("passed", passed), + new XAttribute("failed", failed), + new XAttribute("timeout", timeout), + new XAttribute("notExecuted", notExecuted))); + + if (resultSummaryChildren is not null) + { + resultSummary.Add(resultSummaryChildren); + } + var testRun = new XElement( Ns + "TestRun", new XAttribute("id", Guid.NewGuid()), @@ -247,17 +328,7 @@ private static XDocument BuildReport( new XElement(Ns + "TestDefinitions", testDefinitions ?? []), new XElement(Ns + "TestEntries", testEntries ?? []), new XElement(Ns + "TestLists", testLists ?? DefaultTestLists()), - new XElement( - Ns + "ResultSummary", - new XAttribute("outcome", outcome), - new XElement( - Ns + "Counters", - new XAttribute("total", total), - new XAttribute("executed", passed + failed), - new XAttribute("passed", passed), - new XAttribute("failed", failed), - new XAttribute("timeout", timeout), - new XAttribute("notExecuted", notExecuted)))); + resultSummary); return new XDocument(testRun); } From 46448d77f4d7dfac3efaca8bad8425e16e2382b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Mon, 13 Jul 2026 22:06:27 +0200 Subject: [PATCH 03/26] Address review: harden TRX attachment relocation - Skip relocation when the merged 'In' root overlaps the source 'In' root (output written beside the input with a matching runName), preventing the copy from recursing into its own destination; the original hrefs already resolve there. - Skip file reparse points (symlinks) before File.Copy so a link inside the confined tree can't pull in content from outside it. - Overwrite copied attachment files so a reused output directory can't leave stale bytes behind while the merged XML references the fresh per-input destination. - Add a unit test for the overlapping-root skip case. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxReportEngine.Merge.cs | 22 +++++++++++++--- .../TrxReportEngineMergeTests.cs | 26 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index d0b93d6be4..6f71d19538 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -251,6 +251,16 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO continue; } + // If the merged 'In' root and the source 'In' root overlap (e.g. the output is written + // beside the input and runName matches the input's runDeploymentRoot), copying the source + // into a subfolder of itself would make the recursion re-discover its own destination. + // Skip relocation in that case: the files already live under the merged deployment root, + // so the original hrefs resolve as-is (leave them un-prefixed). + if (IsUnderDirectory(mergedInRoot, sourceInRoot) || IsUnderDirectory(sourceInRoot, mergedInRoot)) + { + continue; + } + // Isolate each input under its own subfolder so identical relative attachment paths // from different inputs cannot shadow each other. string prefix = i.ToString(CultureInfo.InvariantCulture); @@ -324,11 +334,17 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin foreach (string file in Directory.GetFiles(sourceDirectory)) { - string destination = Path.Combine(destinationDirectory, Path.GetFileName(file)); - if (!File.Exists(destination)) + // A file reparse point (symlink) would make File.Copy follow the link and pull in content + // from outside the confined tree; skip it. + if ((File.GetAttributes(file) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) { - File.Copy(file, destination); + continue; } + + // Overwrite so a reused output directory can't leave stale bytes behind while the merged + // XML is rewritten to reference the (per-input isolated) destination, mirroring the + // File.Create overwrite used for the merged TRX itself. + File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true); } foreach (string directory in Directory.GetDirectories(sourceDirectory)) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 014149af13..14470704c2 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -317,6 +317,32 @@ public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_SkipsReloca } } + [TestMethod] + public async Task MergeToFileAsync_WhenMergedRootOverlapsInputTree_SkipsRelocationWithoutRecursing() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Output written beside the input, with runName matching the input's runDeploymentRoot, so + // the merged 'In' root equals the source 'In' root. Relocation must skip (not recurse into + // its own destination) and leave the already-resolvable href untouched. + string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "run", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + Assert.IsTrue(File.Exists(Path.Combine(tempDirectory, "run", "In", "machine", "log.txt"))); + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.Contains("machine/log.txt", hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static string WriteReportWithAttachment(string inputDirectory, string fileName, string deploymentRoot, string attachmentContent) { Directory.CreateDirectory(inputDirectory); From 4ea04cc43a70c6ea919c4fb9473192e85d280963 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 14 Jul 2026 09:47:26 +0200 Subject: [PATCH 04/26] Update .xlf files to be in sync with .resx Regenerated via UpdateXlf. The last localized check-in added translations for 'GlobalTestFixtureShouldBeValidClassLayout' (fr) and 'SlowTestStillRunning' (ru) that XliffTasks rejects (placeholder mismatch), breaking the build with "xlf is out-of-date with resx". UpdateXlf resets those two units to state="new" so the build passes; they will be re-translated on the next localization pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4350f662-0e5d-4e05-b156-1ef619da3b9e --- .../MSTest.Analyzers/xlf/Resources.fr.xlf | 15 +++++++++++++++ .../Resources/xlf/GitHubActionsResources.ru.xlf | 1 + 2 files changed, 16 insertions(+) diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf index 26b74b06d8..f2a02d9a5d 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf @@ -578,6 +578,21 @@ Le type déclarant ces méthodes doit également respecter les règles suivantes - la classe doit être « public » -La classe doit être marquée avec « [TestClass] » (ou un attribut dérivé). - la classe ne doit pas être générique. + Methods marked with '[GlobalTestInitialize]' or '[GlobalTestCleanup]' should follow the following layout to be valid: +-it can't be declared on a generic class +-it should be 'public' +-it should be 'static' +-it should not be 'async void' +-it should not be a special method (finalizer, operator...). +-it should not be generic +-it should take one parameter of type 'TestContext' +-return type should be 'void', 'Task' or 'ValueTask' + +The type declaring these methods should also respect the following rules: +-The type should be a class +-The class should be 'public' +-The class should be marked with '[TestClass]' (or a derived attribute) +-the class should not be generic. {Locked="[GlobalTestInitialize]"}{Locked="[GlobalTestCleanup]"}{Locked="[TestClass]"}{Locked="TestContext"}{Locked="ValueTask"}{Locked="Task"}{Locked="public"}{Locked="static"}{Locked="void"}{Locked="async void"}{Locked="async"}{Locked="class"} diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf index 7cce83381a..9c1d872cae 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf @@ -75,6 +75,7 @@ {0} still running after {1}s {0} все еще выполняется через {1} с + {0} still running after {1}s {0} is the fully qualified test name, {1} is the elapsed seconds. From 3a712c16c5dcb6394e2315f35827ffdf361fde45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 14 Jul 2026 11:43:38 +0200 Subject: [PATCH 05/26] Address review: destination reparse hardening, Kind assertion, revert stray xlf - Harden TRX attachment relocation against a hostile/reused *destination* tree: remove a pre-existing merged 'In' junction/symlink, recreate each per-input destination as a fresh non-link tree, and skip destination dir/file reparse points before writing, so a link can't redirect the copy outside the merged root. - Assert FileArtifactMessage.Kind round-trips in the protocol serializer test. - Revert two .xlf files to their main state (they had malformed double trans-units from an out-of-sync xlf commit; this branch changes no .resx). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../MSTest.Analyzers/xlf/Resources.fr.xlf | 15 ---- .../xlf/GitHubActionsResources.ru.xlf | 1 - .../TrxReportEngine.Merge.cs | 70 +++++++++++++++++-- .../IPC/ProtocolTests.cs | 1 + 4 files changed, 64 insertions(+), 23 deletions(-) diff --git a/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf b/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf index 9f3b2645ec..398d0b548a 100644 --- a/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf +++ b/src/Analyzers/MSTest.Analyzers/xlf/Resources.fr.xlf @@ -573,21 +573,6 @@ The type declaring these methods should also respect the following rules: -it should take one parameter of type 'TestContext' -return type should be 'void', 'Task' or 'ValueTask' -Le type déclarant ces méthodes doit également respecter les règles suivantes : -- le type doit être une classe -- la classe doit être « public » --La classe doit être marquée avec « [TestClass] » (ou un attribut dérivé). -- la classe ne doit pas être générique. - Methods marked with '[GlobalTestInitialize]' or '[GlobalTestCleanup]' should follow the following layout to be valid: --it can't be declared on a generic class --it should be 'public' --it should be 'static' --it should not be 'async void' --it should not be a special method (finalizer, operator...). --it should not be generic --it should take one parameter of type 'TestContext' --return type should be 'void', 'Task' or 'ValueTask' - The type declaring these methods should also respect the following rules: -The type should be a class -The class should be 'public' diff --git a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf index 9c1d872cae..d1245f2365 100644 --- a/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf +++ b/src/Platform/Microsoft.Testing.Extensions.GitHubActionsReport/Resources/xlf/GitHubActionsResources.ru.xlf @@ -74,7 +74,6 @@ {0} still running after {1}s - {0} все еще выполняется через {1} с {0} still running after {1}s {0} is the fully qualified test name, {1} is the elapsed seconds. diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 6f71d19538..6673480850 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -229,6 +229,21 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string mergedInRoot = Path.Combine(mergedRootFull, "In"); + // On a reused output directory the merged 'In' root could pre-exist as a junction/symlink that + // would redirect every copy below it outside the confined merged root. Remove such a link so a + // fresh, real directory is created instead. + if (Directory.Exists(mergedInRoot) && IsReparsePoint(mergedInRoot)) + { + try + { + Directory.Delete(mergedInRoot); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return; + } + } + for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) { try @@ -262,9 +277,13 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } // Isolate each input under its own subfolder so identical relative attachment paths - // from different inputs cannot shadow each other. + // from different inputs cannot shadow each other. Recreate that destination as a fresh, + // non-link tree first so a pre-existing junction/symlink (or stale bytes) from a reused + // output directory can't redirect the copy or linger. string prefix = i.ToString(CultureInfo.InvariantCulture); - CopyDirectoryRecursive(sourceInRoot, Path.Combine(mergedInRoot, prefix)); + string destForInput = Path.Combine(mergedInRoot, prefix); + DeleteDirectoryTreeOrLink(destForInput); + CopyDirectoryRecursive(sourceInRoot, destForInput); RewriteAttachmentHrefs(reports[i], prefix); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) @@ -325,26 +344,41 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin { // Do not descend into reparse points (symlinks/junctions): a link inside the (confined) source // tree could otherwise redirect the copy to an arbitrary location. - if ((File.GetAttributes(sourceDirectory) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + if (IsReparsePoint(sourceDirectory)) { return; } + // The destination is recreated fresh by the caller, but guard defensively for nested levels: + // never write through a destination directory that is itself a link. + if (Directory.Exists(destinationDirectory) && IsReparsePoint(destinationDirectory)) + { + Directory.Delete(destinationDirectory); + } + Directory.CreateDirectory(destinationDirectory); foreach (string file in Directory.GetFiles(sourceDirectory)) { - // A file reparse point (symlink) would make File.Copy follow the link and pull in content - // from outside the confined tree; skip it. - if ((File.GetAttributes(file) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + // A source file reparse point (symlink) would make File.Copy follow the link and pull in + // content from outside the confined tree; skip it. + if (IsReparsePoint(file)) { continue; } + // Guard the destination too: a pre-existing destination symlink would make the overwrite + // follow the link and write outside the merged root. Remove it so a real file is written. + string destination = Path.Combine(destinationDirectory, Path.GetFileName(file)); + if (File.Exists(destination) && IsReparsePoint(destination)) + { + File.Delete(destination); + } + // Overwrite so a reused output directory can't leave stale bytes behind while the merged // XML is rewritten to reference the (per-input isolated) destination, mirroring the // File.Create overwrite used for the merged TRX itself. - File.Copy(file, Path.Combine(destinationDirectory, Path.GetFileName(file)), overwrite: true); + File.Copy(file, destination, overwrite: true); } foreach (string directory in Directory.GetDirectories(sourceDirectory)) @@ -353,6 +387,28 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin } } + private static bool IsReparsePoint(string path) + => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + + private static void DeleteDirectoryTreeOrLink(string path) + { + if (!Directory.Exists(path)) + { + return; + } + + // Deleting a directory reparse point non-recursively removes only the link, never its target's + // contents; a real directory is deleted recursively (the runtime does not follow nested links). + if (IsReparsePoint(path)) + { + Directory.Delete(path); + } + else + { + Directory.Delete(path, recursive: true); + } + } + private static void CloneChildrenInto(XElement? source, XElement destination) { if (source is null) diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs index e6808ef4f9..f87eb784fa 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/IPC/ProtocolTests.cs @@ -405,6 +405,7 @@ public void FileArtifactMessagesSerializeDeserialize() Assert.AreEqual(expected.TestUid, actualArtifact.TestUid); Assert.AreEqual(expected.TestDisplayName, actualArtifact.TestDisplayName); Assert.AreEqual(expected.SessionUid, actualArtifact.SessionUid); + Assert.AreEqual(expected.Kind, actualArtifact.Kind); } } From a5dab5b6b8ddd6b056adaa2f0bdde38422e35508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 14 Jul 2026 13:13:19 +0200 Subject: [PATCH 06/26] Address review: ancestor-symlink hardening + producer-to-wire Kind tests - Reject a reparse-point component anywhere between the confined base and the target for both the merged deployment root and each input's source 'In' tree (HasReparsePointComponent), so a symlinked *ancestor* can't redirect the copy/read outside a lexically-confined path. - Extract the artifact->FileArtifactMessage mapping into internal DotnetTestDataConsumer.CreateFileArtifactMessages factories and add DotnetTestDataConsumerTests asserting Kind is preserved producer-to-wire for both SessionFileArtifact and FileArtifact (and null when unset). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxReportEngine.Merge.cs | 52 ++++++++++++++-- .../InternalAPI/InternalAPI.Unshipped.txt | 2 + .../DotnetTest/IPC/DotnetTestDataConsumer.cs | 61 +++++++++++-------- .../ServerMode/DotnetTestDataConsumerTests.cs | 50 ++++++++++++++- 4 files changed, 133 insertions(+), 32 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 6673480850..a88fb325a3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -221,8 +221,11 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string mergedRootFull = Path.GetFullPath(Path.Combine(outputFull, mergedDeploymentRoot)); // The sanitizer leaves '.' and '..' intact, so a hostile runName ('..') could point the merged - // deployment root outside the output directory. Reject anything that escapes. - if (!IsUnderDirectory(mergedRootFull, outputFull)) + // deployment root outside the output directory. Reject anything that escapes lexically, and also + // reject when the deployment root (or any component of it below the output directory) is a + // reparse point — a symlink/junction there would resolve writes outside the confined root even + // though the lexical check passes. + if (!IsUnderDirectory(mergedRootFull, outputFull) || HasReparsePointComponent(mergedRootFull, outputFull)) { return; } @@ -259,9 +262,14 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } // runDeploymentRoot comes straight from the input TRX; a rooted value or '..' segments - // could make the source tree escape the input report directory. Reject those. + // could make the source tree escape the input report directory. Reject those, and also + // reject when any component from the input directory down to the source 'In' root is a + // reparse point (a symlink/junction there could redirect the read outside the input tree + // even though the lexical confinement check passes). string sourceInRoot = Path.GetFullPath(Path.Combine(inputDirectory, inputDeploymentRoot, "In")); - if (!IsUnderDirectory(sourceInRoot, inputDirectory) || !Directory.Exists(sourceInRoot)) + if (!IsUnderDirectory(sourceInRoot, inputDirectory) + || !Directory.Exists(sourceInRoot) + || HasReparsePointComponent(sourceInRoot, inputDirectory)) { continue; } @@ -335,7 +343,7 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect ? rootFull : rootFull + Path.DirectorySeparatorChar; - StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + StringComparison comparison = PathComparison; return string.Equals(candidateFullPath, rootFull, comparison) || candidateFullPath.StartsWith(rootWithSeparator, comparison); } @@ -390,6 +398,40 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin private static bool IsReparsePoint(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + /// + /// Returns if any existing directory component strictly below + /// up to and including is a + /// reparse point (symlink/junction). Both paths are expected to be normalized full paths with + /// under . A symlinked ancestor + /// can redirect reads/writes outside a lexically-confined path, so callers reject such trees. + /// + private static bool HasReparsePointComponent(string candidateFullPath, string baseDirectory) + { + string baseFull = Path.GetFullPath(baseDirectory); + string current = candidateFullPath; + + while (!string.Equals(current, baseFull, PathComparison) && IsUnderDirectory(current, baseFull)) + { + if (Directory.Exists(current) && IsReparsePoint(current)) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (RoslynString.IsNullOrEmpty(parent) || string.Equals(parent, current, PathComparison)) + { + break; + } + + current = parent; + } + + return false; + } + + private static StringComparison PathComparison + => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + private static void DeleteDirectoryTreeOrLink(string path) { if (!Directory.Exists(path)) diff --git a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt index d806f64cd1..64098b9ec6 100644 --- a/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Platform/InternalAPI/InternalAPI.Unshipped.txt @@ -1,5 +1,7 @@ #nullable enable const Microsoft.Testing.Platform.IPC.FileArtifactMessageFieldsId.Kind = 7 -> ushort +static Microsoft.Testing.Platform.IPC.DotnetTestDataConsumer.CreateFileArtifactMessages(string? executionId, Microsoft.Testing.Platform.Extensions.Messages.FileArtifact! fileArtifact) -> Microsoft.Testing.Platform.IPC.Models.FileArtifactMessages! +static Microsoft.Testing.Platform.IPC.DotnetTestDataConsumer.CreateFileArtifactMessages(string? executionId, Microsoft.Testing.Platform.Extensions.Messages.SessionFileArtifact! sessionFileArtifact) -> Microsoft.Testing.Platform.IPC.Models.FileArtifactMessages! Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Deconstruct(out string? FullPath, out string? DisplayName, out string? Description, out string? TestUid, out string? TestDisplayName, out string? SessionUid, out string? Kind) -> void Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.FileArtifactMessage(string? FullPath, string? DisplayName, string? Description, string? TestUid, string? TestDisplayName, string? SessionUid, string? Kind) -> void Microsoft.Testing.Platform.IPC.Models.FileArtifactMessage.Kind.get -> string? diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs index 3ae4e87537..3bdd938d8b 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs @@ -175,43 +175,52 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella break; case SessionFileArtifact sessionFileArtifact: - var fileArtifactMessages = new FileArtifactMessages( - _executionId, - DotnetTestConnection.InstanceId, - [ - new FileArtifactMessage( - sessionFileArtifact.FileInfo.FullName, - sessionFileArtifact.DisplayName, - sessionFileArtifact.Description ?? string.Empty, - string.Empty, - string.Empty, - sessionFileArtifact.SessionUid.Value, - sessionFileArtifact.Kind) - ]); + var fileArtifactMessages = CreateFileArtifactMessages(_executionId, sessionFileArtifact); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); break; case FileArtifact fileArtifact: - fileArtifactMessages = new( - _executionId, - DotnetTestConnection.InstanceId, - [ - new FileArtifactMessage( - fileArtifact.FileInfo.FullName, - fileArtifact.DisplayName, - fileArtifact.Description ?? string.Empty, - string.Empty, - string.Empty, - string.Empty, - fileArtifact.Kind) - ]); + fileArtifactMessages = CreateFileArtifactMessages(_executionId, fileArtifact); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); break; } } + // Maps a session-scoped artifact to its wire message. Extracted so the producer-to-wire mapping + // (in particular the Kind carried for post-processing grouping) is unit-testable without a live pipe. + internal static FileArtifactMessages CreateFileArtifactMessages(string? executionId, SessionFileArtifact sessionFileArtifact) + => new( + executionId, + DotnetTestConnection.InstanceId, + [ + new FileArtifactMessage( + sessionFileArtifact.FileInfo.FullName, + sessionFileArtifact.DisplayName, + sessionFileArtifact.Description ?? string.Empty, + string.Empty, + string.Empty, + sessionFileArtifact.SessionUid.Value, + sessionFileArtifact.Kind) + ]); + + // Maps a (non session-scoped) artifact to its wire message. See the SessionFileArtifact overload. + internal static FileArtifactMessages CreateFileArtifactMessages(string? executionId, FileArtifact fileArtifact) + => new( + executionId, + DotnetTestConnection.InstanceId, + [ + new FileArtifactMessage( + fileArtifact.FileInfo.FullName, + fileArtifact.DisplayName, + fileArtifact.Description ?? string.Empty, + string.Empty, + string.Empty, + string.Empty, + fileArtifact.Kind) + ]); + private static TestNodeDetails? GetTestNodeDetails(TestNodeUpdateMessage testNodeUpdateMessage) { byte? state = null; diff --git a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs index 179f48b46b..5366016fdd 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.UnitTests/ServerMode/DotnetTestDataConsumerTests.cs @@ -1,10 +1,11 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. +// Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. using System.Reflection; using Microsoft.Testing.Platform.Extensions.Messages; using Microsoft.Testing.Platform.IPC; +using Microsoft.Testing.Platform.IPC.Models; using Microsoft.Testing.Platform.TestHost; namespace Microsoft.Testing.Platform.UnitTests; @@ -81,6 +82,53 @@ public void GetTestNodeDetails_WhenErroredWithAssertData_DoesNotPopulateExpected Assert.IsNull(details.Actual); } + [TestMethod] + public void CreateFileArtifactMessages_FromSessionFileArtifact_PreservesKind() + { + // Exercises the producer-to-wire mapping the serializer round-trip tests bypass (they start from + // an already-populated FileArtifactMessage). Without this a regression that dropped the Kind here + // would break post-processing grouping while every serializer test stayed green. + var artifact = new SessionFileArtifact(new SessionUid("session"), new FileInfo("/path/a.trx"), "a.trx", "desc", "microsoft.testing.trx"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.AreEqual("exec-1", messages.ExecutionId); + Assert.HasCount(1, messages.FileArtifacts); + Assert.AreEqual("microsoft.testing.trx", messages.FileArtifacts[0].Kind); + Assert.AreEqual("session", messages.FileArtifacts[0].SessionUid); + } + + [TestMethod] + public void CreateFileArtifactMessages_FromSessionFileArtifactWithoutKind_SendsNullKind() + { + var artifact = new SessionFileArtifact(new SessionUid("session"), new FileInfo("/path/a.txt"), "a.txt"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.IsNull(messages.FileArtifacts[0].Kind); + } + + [TestMethod] + public void CreateFileArtifactMessages_FromFileArtifact_PreservesKind() + { + var artifact = new FileArtifact(new FileInfo("/path/a.trx"), "a.trx", "desc", "microsoft.testing.trx"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.HasCount(1, messages.FileArtifacts); + Assert.AreEqual("microsoft.testing.trx", messages.FileArtifacts[0].Kind); + } + + [TestMethod] + public void CreateFileArtifactMessages_FromFileArtifactWithoutKind_SendsNullKind() + { + var artifact = new FileArtifact(new FileInfo("/path/a.txt"), "a.txt"); + + FileArtifactMessages messages = DotnetTestDataConsumer.CreateFileArtifactMessages("exec-1", artifact); + + Assert.IsNull(messages.FileArtifacts[0].Kind); + } + private static DotnetTestDataConsumer.TestNodeDetails InvokeGetTestNodeDetails(IProperty stateProperty) { TestNodeUpdateMessage testNodeUpdateMessage = new(new SessionUid("session"), new TestNode From a468d2ee77a80a31431383e4b3503a381869d201 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 14 Jul 2026 13:29:58 +0200 Subject: [PATCH 07/26] Address review: CTRF tool/env attribution + TRX cancellation & path-format failures - CTRF merge no longer misattributes a single framework across modules: the merged 'tool' keeps a concrete identity only when all inputs agree, otherwise a neutral merger identity is used; module-specific environment.extra fields (testApplication, exitCode) are dropped rather than presented as describing every merged module. - TRX attachment relocation now honors the cancellation token (checked per input and during directory/file iteration) so a cancel after load doesn't run to completion. - Malformed attacker-controlled runDeploymentRoot values that make Path.GetFullPath throw ArgumentException/NotSupportedException now skip that input instead of aborting the whole merge, matching the never-fail attachment policy. - Add CTRF tests for shared/differing tool identity and dropped env fields. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 41 ++++++++++++++-- .../TrxReportEngine.Merge.cs | 25 +++++++--- .../CtrfReportMergerTests.cs | 48 +++++++++++++++++-- 3 files changed, 100 insertions(+), 14 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index af1ac61488..edb208b5a2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -22,6 +22,10 @@ namespace Microsoft.Testing.Extensions.CtrfReport; /// internal static class CtrfReportMerger { + // Neutral tool identity used when merged inputs disagree on their producing test framework, so the + // merged report does not misattribute one framework's identity to another's tests. + private const string MergedToolName = "Microsoft.Testing.Extensions.CtrfReport (merged)"; + internal static string Merge(IReadOnlyList inputReports) { if (inputReports is null) @@ -40,6 +44,12 @@ internal static string Merge(IReadOnlyList inputReports) long? earliestStart = null; long? latestStop = null; + // A same-kind merge can combine modules produced by different test frameworks. Track the + // distinct tool identities so the merged report is only stamped with a single framework when + // every input actually used it; otherwise a neutral merger identity is used (see below). + var distinctToolNames = new HashSet(StringComparer.Ordinal); + JsonNode? firstTool = null; + foreach (string reportJson in inputReports) { if (JsonNode.Parse(reportJson) is not JsonObject root) @@ -58,6 +68,12 @@ internal static string Merge(IReadOnlyList inputReports) } } + if (results?["tool"] is JsonNode toolNode) + { + firstTool ??= toolNode; + distinctToolNames.Add((string?)toolNode["name"] ?? string.Empty); + } + JsonNode? summary = results?["summary"]; if (summary is not null) { @@ -122,16 +138,33 @@ internal static string Merge(IReadOnlyList inputReports) }; var resultsObject = new JsonObject(); - if (first["results"]?["tool"] is JsonNode tool) + + // Only carry a concrete tool identity when every input reported the same one (the common + // single-framework case). When inputs disagree, stamping the first framework onto all tests + // would misattribute the others, so use a neutral merger identity instead. + if (distinctToolNames.Count == 1 && firstTool is not null) { - resultsObject["tool"] = tool.DeepClone(); + resultsObject["tool"] = firstTool.DeepClone(); + } + else + { + resultsObject["tool"] = new JsonObject { ["name"] = MergedToolName }; } resultsObject["summary"] = summaryObject; - if (first["results"]?["environment"] is JsonNode environment) + // The environment is taken from the first report for shared fields (OS, user, machine), but + // module-specific values under 'extra' (the producing test application and its exit code) cannot + // describe all merged modules, so they are dropped rather than misattributed. + if (first["results"]?["environment"] is JsonNode environment && environment.DeepClone() is JsonObject mergedEnvironment) { - resultsObject["environment"] = environment.DeepClone(); + if (mergedEnvironment["extra"] is JsonObject environmentExtra) + { + environmentExtra.Remove("testApplication"); + environmentExtra.Remove("exitCode"); + } + + resultsObject["environment"] = mergedEnvironment; } resultsObject["tests"] = mergedTests; diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index a88fb325a3..65fc0f2de6 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -190,7 +190,7 @@ internal static async Task MergeToFileAsync( // deployment root — isolated per input so identical file names from different modules do not // collide — and rewrite the input's hrefs to match, before the merge clones them. // Best-effort and path-confined: failures never fail the merge. - RelocateAttachments(inputPaths, reports, outputDirectory, runName); + RelocateAttachments(inputPaths, reports, outputDirectory, runName, cancellationToken); XDocument merged = Merge(reports, runId, runName); @@ -214,7 +214,7 @@ internal static async Task MergeToFileAsync( /// (runName and each input's runDeploymentRoot are attacker-influenced), and any /// copy failure is swallowed so it never fails the merge (never-fail-the-run invariant). /// - private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, string runName) + private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, string runName, CancellationToken cancellationToken) { string outputFull = Path.GetFullPath(outputDirectory); string mergedDeploymentRoot = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); @@ -249,6 +249,11 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) { + // Cancellation must interrupt an otherwise unbounded copy of a large deployment tree; check + // before each input (and inside CopyDirectoryRecursive) and let OperationCanceledException + // propagate rather than being swallowed as a best-effort failure below. + cancellationToken.ThrowIfCancellationRequested(); + try { string? inputDirectory = Path.GetDirectoryName(Path.GetFullPath(inputPaths[i])); @@ -291,12 +296,14 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string prefix = i.ToString(CultureInfo.InvariantCulture); string destForInput = Path.Combine(mergedInRoot, prefix); DeleteDirectoryTreeOrLink(destForInput); - CopyDirectoryRecursive(sourceInRoot, destForInput); + CopyDirectoryRecursive(sourceInRoot, destForInput, cancellationToken); RewriteAttachmentHrefs(reports[i], prefix); } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { - // Best-effort: a failed attachment copy must not fail the merge. + // Best-effort: a failed attachment copy (including a malformed path from a hostile + // runDeploymentRoot, which Path.GetFullPath surfaces as ArgumentException/ + // NotSupportedException) must not fail the merge. Cancellation is not caught here. } } } @@ -348,8 +355,10 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect || candidateFullPath.StartsWith(rootWithSeparator, comparison); } - private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory) + private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); + // Do not descend into reparse points (symlinks/junctions): a link inside the (confined) source // tree could otherwise redirect the copy to an arbitrary location. if (IsReparsePoint(sourceDirectory)) @@ -368,6 +377,8 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin foreach (string file in Directory.GetFiles(sourceDirectory)) { + cancellationToken.ThrowIfCancellationRequested(); + // A source file reparse point (symlink) would make File.Copy follow the link and pull in // content from outside the confined tree; skip it. if (IsReparsePoint(file)) @@ -391,7 +402,7 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin foreach (string directory in Directory.GetDirectories(sourceDirectory)) { - CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory))); + CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), cancellationToken); } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index 598228f7f2..107b05f7c7 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -76,10 +76,10 @@ public void Merge_SummaryStartIsEarliestAndStopIsLatest() } [TestMethod] - public void Merge_KeepsReportFormatAndToolFromFirstReport() + public void Merge_WhenAllInputsShareTool_KeepsThatTool() { string a = BuildReport(toolName: "MSTest"); - string b = BuildReport(toolName: "OtherFramework"); + string b = BuildReport(toolName: "MSTest"); JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!; @@ -87,6 +87,38 @@ public void Merge_KeepsReportFormatAndToolFromFirstReport() Assert.AreEqual("MSTest", (string?)merged["results"]!["tool"]!["name"]); } + [TestMethod] + public void Merge_WhenInputsUseDifferentTools_UsesNeutralMergerToolIdentity() + { + // Merging modules produced by different frameworks must not misattribute one framework's + // identity to another's tests, so a neutral merger identity is used instead of the first tool. + string a = BuildReport(toolName: "MSTest"); + string b = BuildReport(toolName: "OtherFramework"); + + string? toolName = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["tool"]!["name"]; + + Assert.AreNotEqual("MSTest", toolName); + Assert.AreNotEqual("OtherFramework", toolName); + Assert.Contains("merged", toolName!); + } + + [TestMethod] + public void Merge_DropsModuleSpecificEnvironmentExtraFields() + { + // testApplication/exitCode describe a single module and cannot describe all merged modules, so + // they must not be carried over (misattributing the first module's app/exit code to everyone). + string a = BuildReport(); + string b = BuildReport(); + + JsonNode? environmentExtra = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["environment"]!["extra"]; + + Assert.IsNotNull(environmentExtra); + Assert.IsNull(environmentExtra["testApplication"]); + Assert.IsNull(environmentExtra["exitCode"]); + // Shared, non-module-specific fields are retained. + Assert.AreEqual("someone", (string?)environmentExtra["user"]); + } + [TestMethod] public void Merge_GeneratesFreshReportId() { @@ -175,7 +207,17 @@ private static string BuildReport( ["stop"] = stop, ["duration"] = Math.Max(0, stop - start), }, - ["environment"] = new JsonObject { ["osPlatform"] = "test" }, + ["environment"] = new JsonObject + { + ["osPlatform"] = "test", + ["extra"] = new JsonObject + { + ["user"] = "someone", + ["machine"] = "box", + ["testApplication"] = "A.dll", + ["exitCode"] = 0, + }, + }, ["tests"] = testArray, }, }; From 1c197f47d466e1b71075c88c287112563ade882b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Tue, 14 Jul 2026 14:26:51 +0200 Subject: [PATCH 08/26] Address review: TRX nested-overlap relocation + CTRF full tool identity - TRX attachment relocation now distinguishes equal roots (skip, hrefs already resolve) from strict-nested overlaps: for the latter it stages the copy through a temp directory outside both trees (staging the source before clearing the destination, which may sit inside the source), so nested layouts no longer produce dangling attachment references. - CTRF tool agreement is now compared on the complete tool object (not just name), and an input missing 'tool' counts as disagreement, so same-name/different-version or tagged/untagged mixes fall back to the neutral merger identity. Also expressed the tool selection as a single conditional assignment. - Add tests for nested-overlap relocation and the stricter tool-identity rules. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 35 ++++++---- .../TrxReportEngine.Merge.cs | 68 +++++++++++++++---- .../CtrfReportMergerTests.cs | 50 +++++++++++++- .../TrxReportEngineMergeTests.cs | 30 ++++++++ 4 files changed, 155 insertions(+), 28 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index edb208b5a2..415dd73fa4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -45,10 +45,13 @@ internal static string Merge(IReadOnlyList inputReports) long? latestStop = null; // A same-kind merge can combine modules produced by different test frameworks. Track the - // distinct tool identities so the merged report is only stamped with a single framework when - // every input actually used it; otherwise a neutral merger identity is used (see below). - var distinctToolNames = new HashSet(StringComparer.Ordinal); + // distinct *complete* tool identities (full serialized tool object, not just its name) so the + // merged report is only stamped with a concrete framework when every input reported the exact + // same tool; otherwise a neutral merger identity is used (see below). An input that omits 'tool' + // counts as a distinct (missing) identity, so a mix of tagged/untagged inputs also degrades. + var distinctToolIdentities = new HashSet(StringComparer.Ordinal); JsonNode? firstTool = null; + int reportCount = 0; foreach (string reportJson in inputReports) { @@ -58,6 +61,7 @@ internal static string Merge(IReadOnlyList inputReports) } first ??= root; + reportCount++; JsonNode? results = root["results"]; if (results?["tests"] is JsonArray testArray) @@ -71,7 +75,11 @@ internal static string Merge(IReadOnlyList inputReports) if (results?["tool"] is JsonNode toolNode) { firstTool ??= toolNode; - distinctToolNames.Add((string?)toolNode["name"] ?? string.Empty); + distinctToolIdentities.Add(toolNode.ToJsonString()); + } + else + { + distinctToolIdentities.Add(string.Empty); } JsonNode? summary = results?["summary"]; @@ -139,17 +147,14 @@ internal static string Merge(IReadOnlyList inputReports) var resultsObject = new JsonObject(); - // Only carry a concrete tool identity when every input reported the same one (the common - // single-framework case). When inputs disagree, stamping the first framework onto all tests - // would misattribute the others, so use a neutral merger identity instead. - if (distinctToolNames.Count == 1 && firstTool is not null) - { - resultsObject["tool"] = firstTool.DeepClone(); - } - else - { - resultsObject["tool"] = new JsonObject { ["name"] = MergedToolName }; - } + // Only carry a concrete tool identity when every input reported the exact same one (the common + // single-framework case). When inputs disagree — different tool objects, or a mix of tagged and + // untagged inputs — stamping the first framework onto all tests would misattribute the others, + // so use a neutral merger identity instead. + bool allInputsShareTool = distinctToolIdentities.Count == 1 && firstTool is not null && reportCount > 0; + resultsObject["tool"] = allInputsShareTool + ? firstTool!.DeepClone() + : new JsonObject { ["name"] = MergedToolName }; resultsObject["summary"] = summaryObject; diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 65fc0f2de6..f2b3a1b48d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -279,24 +279,41 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO continue; } - // If the merged 'In' root and the source 'In' root overlap (e.g. the output is written - // beside the input and runName matches the input's runDeploymentRoot), copying the source - // into a subfolder of itself would make the recursion re-discover its own destination. - // Skip relocation in that case: the files already live under the merged deployment root, - // so the original hrefs resolve as-is (leave them un-prefixed). - if (IsUnderDirectory(mergedInRoot, sourceInRoot) || IsUnderDirectory(sourceInRoot, mergedInRoot)) + // Equal roots: the source files already live at the merged deployment root under their + // original relative paths, so the original hrefs resolve as-is. Skip relocation and + // leave the references un-prefixed. + if (string.Equals(sourceInRoot, mergedInRoot, PathComparison)) { continue; } - // Isolate each input under its own subfolder so identical relative attachment paths - // from different inputs cannot shadow each other. Recreate that destination as a fresh, - // non-link tree first so a pre-existing junction/symlink (or stale bytes) from a reused - // output directory can't redirect the copy or linger. string prefix = i.ToString(CultureInfo.InvariantCulture); string destForInput = Path.Combine(mergedInRoot, prefix); - DeleteDirectoryTreeOrLink(destForInput); - CopyDirectoryRecursive(sourceInRoot, destForInput, cancellationToken); + + // Strict overlap (one 'In' root is nested inside the other): copying source directly + // into a subfolder of the merged root would either recurse into its own destination + // (merged nested under source) or, if we simply skipped, leave the prefixed hrefs + // dangling. Stage the copy through a temporary directory outside both trees, then move + // it into place, so the per-input prefix rewrite below is always correct. + bool strictOverlap = IsUnderDirectory(mergedInRoot, sourceInRoot) || IsUnderDirectory(sourceInRoot, mergedInRoot); + + // Isolate each input under its own subfolder so identical relative attachment paths + // from different inputs cannot shadow each other. + if (strictOverlap) + { + // The destination is nested with the source, so stage the source out first (before + // clearing the destination, which could otherwise be inside the source tree). + CopyViaStaging(sourceInRoot, destForInput, cancellationToken); + } + else + { + // Recreate the destination as a fresh, non-link tree first so a pre-existing + // junction/symlink (or stale bytes) from a reused output directory can't redirect the + // copy or linger. + DeleteDirectoryTreeOrLink(destForInput); + CopyDirectoryRecursive(sourceInRoot, destForInput, cancellationToken); + } + RewriteAttachmentHrefs(reports[i], prefix); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) @@ -355,6 +372,33 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect || candidateFullPath.StartsWith(rootWithSeparator, comparison); } + /// + /// Copies to via a + /// temporary staging directory outside both trees. Used when the source and the merged destination + /// strictly overlap, so the copy never recurses into its own destination while still landing the + /// files at the prefixed destination (keeping the rewritten hrefs valid). + /// + private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + { + string staging = Path.Combine(Path.GetTempPath(), "mtp-trx-merge-" + Guid.NewGuid().ToString("N")); + try + { + // Copy the source out to a temp location OUTSIDE both trees first, so clearing the + // destination (which may be nested inside the source) cannot corrupt the source, and the + // final copy never recurses into its own destination. + CopyDirectoryRecursive(sourceDirectory, staging, cancellationToken); + DeleteDirectoryTreeOrLink(destinationDirectory); + CopyDirectoryRecursive(staging, destinationDirectory, cancellationToken); + } + finally + { + if (Directory.Exists(staging)) + { + Directory.Delete(staging, recursive: true); + } + } + } + private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index 107b05f7c7..db475a93ce 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -75,6 +75,30 @@ public void Merge_SummaryStartIsEarliestAndStopIsLatest() Assert.AreEqual(4000, (long)summary["duration"]!); } + [TestMethod] + public void Merge_WhenInputsShareToolNameButDifferentVersion_UsesNeutralMergerToolIdentity() + { + // Same tool name but different version/metadata is still a distinct identity and must not be + // stamped onto every merged test. + string a = BuildReport(toolName: "MSTest", toolVersion: "1.0.0"); + string b = BuildReport(toolName: "MSTest", toolVersion: "2.0.0"); + + string? toolName = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["tool"]!["name"]; + + Assert.Contains("merged", toolName!); + } + + [TestMethod] + public void Merge_WhenOneInputMissingTool_UsesNeutralMergerToolIdentity() + { + string a = BuildReport(toolName: "MSTest"); + string b = BuildReportWithoutTool(); + + string? toolName = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["tool"]!["name"]; + + Assert.Contains("merged", toolName!); + } + [TestMethod] public void Merge_WhenAllInputsShareTool_KeepsThatTool() { @@ -176,6 +200,7 @@ private static string BuildReport( long start = 1000, long stop = 2000, string toolName = "MSTest", + string? toolVersion = null, IEnumerable? testEntries = null) { var testArray = new JsonArray(); @@ -184,6 +209,12 @@ private static string BuildReport( testArray.Add(test); } + var toolObject = new JsonObject { ["name"] = toolName }; + if (toolVersion is not null) + { + toolObject["version"] = toolVersion; + } + var report = new JsonObject { ["reportFormat"] = "CTRF", @@ -193,7 +224,7 @@ private static string BuildReport( ["generatedBy"] = "Microsoft.Testing.Extensions.CtrfReport", ["results"] = new JsonObject { - ["tool"] = new JsonObject { ["name"] = toolName }, + ["tool"] = toolObject, ["summary"] = new JsonObject { ["tests"] = tests, @@ -247,4 +278,21 @@ private static string BuildReportWithoutSummary(params JsonObject[] testEntries) return report.ToJsonString(); } + + private static string BuildReportWithoutTool() + { + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["reportId"] = Guid.NewGuid().ToString("D"), + ["results"] = new JsonObject + { + ["summary"] = new JsonObject { ["tests"] = 1, ["passed"] = 1, ["start"] = 1000, ["stop"] = 2000 }, + ["tests"] = new JsonArray(Test("t", "passed")), + }, + }; + + return report.ToJsonString(); + } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 14470704c2..77d00f8623 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -343,6 +343,36 @@ public async Task MergeToFileAsync_WhenMergedRootOverlapsInputTree_SkipsRelocati } } + [TestMethod] + public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndKeepsHrefsValid() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Output written into a subfolder whose deployment root ('run') sits ABOVE the input's own + // deployment tree, so the source 'In' root is strictly nested under the merged 'In' root. + // Relocation must stage the copy so the merged TRX's rewritten href points at real bytes. + string inputDir = Path.Combine(tempDirectory, "run", "In", "child"); + string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + // The rewritten href must resolve to a real file under the merged deployment root. + string resolved = Path.Combine(tempDirectory, "run", "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static string WriteReportWithAttachment(string inputDirectory, string fileName, string deploymentRoot, string attachmentContent) { Directory.CreateDirectory(inputDirectory); From 579f8b22b470fef9423c23d7ec2430d54c401de0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 13:09:30 +0200 Subject: [PATCH 09/26] Address review: CTRF test-level timing fallback + TRX nested staging exclusion - CTRF merge now falls back to per-test start/stop when an input has no summary, so a summary-less (but timed) module still contributes to the merged min/max instead of being dropped or collapsing the window to the Unix epoch. - TRX nested-overlap staging now excludes the merged 'In' subtree only in the direction where it is nested inside the source, so repeated merges into a reused output no longer snapshot the previous merged tree and accumulate recursively-nested stale trees without bound. - Add tests for summary-less timing and repeated nested-layout merges. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 30 +++++++++++++++--- .../TrxReportEngine.Merge.cs | 31 ++++++++++++++----- .../CtrfReportMergerTests.cs | 24 ++++++++++++++ .../TrxReportEngineMergeTests.cs | 31 +++++++++++++++++++ 4 files changed, 105 insertions(+), 11 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 415dd73fa4..f11a7fe1ee 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -69,6 +69,22 @@ internal static string Merge(IReadOnlyList inputReports) foreach (JsonNode? test in testArray) { mergedTests.Add(test?.DeepClone()); + + // Fall back to per-test timing so a summary-less input (which the merger explicitly + // supports) still contributes to the merged min/max instead of being dropped or + // forcing the merged timestamp back to the Unix epoch. + if (test is not null) + { + if (TryReadLong(test, "start", out long testStart)) + { + earliestStart = Min(earliestStart, testStart); + } + + if (TryReadLong(test, "stop", out long testStop)) + { + latestStop = Max(latestStop, testStop); + } + } } } @@ -85,14 +101,14 @@ internal static string Merge(IReadOnlyList inputReports) JsonNode? summary = results?["summary"]; if (summary is not null) { - if (TryReadLong(summary, "start", out long start) && (earliestStart is null || start < earliestStart)) + if (TryReadLong(summary, "start", out long start)) { - earliestStart = start; + earliestStart = Min(earliestStart, start); } - if (TryReadLong(summary, "stop", out long stop) && (latestStop is null || stop > latestStop)) + if (TryReadLong(summary, "stop", out long stop)) { - latestStop = stop; + latestStop = Max(latestStop, stop); } } } @@ -255,4 +271,10 @@ private static bool TryReadLong(JsonNode summary, string propertyName, out long return false; } + + private static long Min(long? current, long candidate) + => current is null || candidate < current ? candidate : current.Value; + + private static long Max(long? current, long candidate) + => current is null || candidate > current ? candidate : current.Value; } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index f2b3a1b48d..0df656365a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -301,9 +301,14 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO // from different inputs cannot shadow each other. if (strictOverlap) { - // The destination is nested with the source, so stage the source out first (before - // clearing the destination, which could otherwise be inside the source tree). - CopyViaStaging(sourceInRoot, destForInput, cancellationToken); + // Stage the source out first (before clearing the destination, which could otherwise + // be inside the source tree). Only when the merged root is nested INSIDE the source + // does the source snapshot also contain the previous merged 'In' tree; exclude it in + // that direction so repeated merges don't accumulate recursively-nested stale trees. + string? excludeSubtree = IsUnderDirectory(mergedInRoot, sourceInRoot) && !string.Equals(mergedInRoot, sourceInRoot, PathComparison) + ? mergedInRoot + : null; + CopyViaStaging(sourceInRoot, destForInput, excludeSubtree, cancellationToken); } else { @@ -377,8 +382,10 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect /// temporary staging directory outside both trees. Used when the source and the merged destination /// strictly overlap, so the copy never recurses into its own destination while still landing the /// files at the prefixed destination (keeping the rewritten hrefs valid). + /// (the merged 'In' root) is skipped while staging so a source that + /// contains the previous merged output does not snapshot it into the new destination. /// - private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, string? excludeSubtree, CancellationToken cancellationToken) { string staging = Path.Combine(Path.GetTempPath(), "mtp-trx-merge-" + Guid.NewGuid().ToString("N")); try @@ -386,9 +393,9 @@ private static void CopyViaStaging(string sourceDirectory, string destinationDir // Copy the source out to a temp location OUTSIDE both trees first, so clearing the // destination (which may be nested inside the source) cannot corrupt the source, and the // final copy never recurses into its own destination. - CopyDirectoryRecursive(sourceDirectory, staging, cancellationToken); + CopyDirectoryRecursive(sourceDirectory, staging, excludeSubtree, cancellationToken); DeleteDirectoryTreeOrLink(destinationDirectory); - CopyDirectoryRecursive(staging, destinationDirectory, cancellationToken); + CopyDirectoryRecursive(staging, destinationDirectory, excludeSubtree: null, cancellationToken); } finally { @@ -400,9 +407,19 @@ private static void CopyViaStaging(string sourceDirectory, string destinationDir } private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) + => CopyDirectoryRecursive(sourceDirectory, destinationDirectory, excludeSubtree: null, cancellationToken); + + private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, string? excludeSubtree, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); + // Skip the excluded subtree (e.g. a previous merged 'In' root nested under the source) so we + // don't snapshot the merger's own prior output into the new destination. + if (excludeSubtree is not null && IsUnderDirectory(Path.GetFullPath(sourceDirectory), excludeSubtree)) + { + return; + } + // Do not descend into reparse points (symlinks/junctions): a link inside the (confined) source // tree could otherwise redirect the copy to an arbitrary location. if (IsReparsePoint(sourceDirectory)) @@ -446,7 +463,7 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin foreach (string directory in Directory.GetDirectories(sourceDirectory)) { - CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), cancellationToken); + CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), excludeSubtree, cancellationToken); } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index db475a93ce..d5a3825ebb 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -279,6 +279,30 @@ private static string BuildReportWithoutSummary(params JsonObject[] testEntries) return report.ToJsonString(); } + [TestMethod] + public void Merge_UsesTestLevelTimingWhenSummaryMissing() + { + // A summary-less input still carries per-test start/stop; those must feed the merged min/max + // rather than being dropped (which would make the merged window fall back to the epoch). + string withSummary = BuildReport(start: 5000, stop: 6000); + string withoutSummary = BuildReportWithoutSummary(TimedTest("t", 1000, 9000)); + + JsonNode summary = JsonNode.Parse(CtrfReportMerger.Merge([withSummary, withoutSummary]))!["results"]!["summary"]!; + + Assert.AreEqual(1000, (long)summary["start"]!); + Assert.AreEqual(9000, (long)summary["stop"]!); + } + + private static JsonObject TimedTest(string name, long start, long stop) + => new() + { + ["name"] = name, + ["status"] = "passed", + ["duration"] = stop - start, + ["start"] = start, + ["stop"] = stop, + }; + private static string BuildReportWithoutTool() { var report = new JsonObject diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 77d00f8623..93509f53bb 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -373,6 +373,37 @@ public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndK } } + [TestMethod] + public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_DoesNotAccumulateNestedTrees() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Layout where the merged deployment root is nested strictly INSIDE the input's source 'In' + // tree, so staging the whole source would capture the previous merged output. Merging + // repeatedly into the same output must not snapshot the prior merged tree into the new one + // (which would grow "0/0/0/..." without bound). + string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "dep", "In", "out", "merged.trx"); + + for (int run = 0; run < 4; run++) + { + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + } + + Assert.IsTrue(File.Exists(output)); + + // Original source attachment + a single relocated copy — bounded, not one-per-run. + List copies = [.. Directory.GetFiles(tempDirectory, "log.txt", SearchOption.AllDirectories)]; + Assert.HasCount(2, copies); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static string WriteReportWithAttachment(string inputDirectory, string fileName, string deploymentRoot, string attachmentContent) { Directory.CreateDirectory(inputDirectory); From 97dcbb0b8e52fcee73b162ddd11d9625b1c42b69 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 13:14:28 +0200 Subject: [PATCH 10/26] Address review: never delete input trees during TRX attachment relocation - Relocation no longer wholesale-deletes a per-input destination folder when it contains an input report/source tree; it overlays the copy instead (per-file overwrite, stale destination reparse points still removed). This preserves the originals RFC 018 requires to remain on disk and stops a destination that overlaps a later input from deleting that input's report/attachments before it is processed. - Add a test asserting the original report and attachment survive when the input lives under the merged 'In' root. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxReportEngine.Merge.cs | 65 +++++++++++++++++-- .../TrxReportEngineMergeTests.cs | 26 ++++++++ 2 files changed, 84 insertions(+), 7 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 0df656365a..94280c321e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -232,6 +232,25 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string mergedInRoot = Path.Combine(mergedRootFull, "In"); + // Absolute directories of every input report. A destination folder must never be wholesale- + // deleted when it contains one of these, otherwise relocation would remove the originals (which + // RFC 018 requires to stay on disk) or a sibling/later input's report and attachments. + var protectedInputDirectories = new List(inputPaths.Count); + foreach (string inputPath in inputPaths) + { + try + { + if (Path.GetDirectoryName(Path.GetFullPath(inputPath)) is { Length: > 0 } dir) + { + protectedInputDirectories.Add(dir); + } + } + catch (Exception ex) when (ex is ArgumentException or NotSupportedException) + { + // Ignore malformed input paths here; the per-input loop handles them. + } + } + // On a reused output directory the merged 'In' root could pre-exist as a junction/symlink that // would redirect every copy below it outside the confined merged root. Remove such a link so a // fresh, real directory is created instead. @@ -299,6 +318,13 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO // Isolate each input under its own subfolder so identical relative attachment paths // from different inputs cannot shadow each other. + // + // Only clear the destination wholesale when it does not contain an input report tree. + // If it does (e.g. an input lives under the merged 'In' root), a blanket delete would + // remove originals or sibling/later inputs, so we overlay the copy instead (per-file + // overwrite; CopyDirectoryRecursive still removes stale destination reparse points). + bool destinationContainsInput = ContainsAnyDirectory(destForInput, protectedInputDirectories); + if (strictOverlap) { // Stage the source out first (before clearing the destination, which could otherwise @@ -308,14 +334,18 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string? excludeSubtree = IsUnderDirectory(mergedInRoot, sourceInRoot) && !string.Equals(mergedInRoot, sourceInRoot, PathComparison) ? mergedInRoot : null; - CopyViaStaging(sourceInRoot, destForInput, excludeSubtree, cancellationToken); + CopyViaStaging(sourceInRoot, destForInput, excludeSubtree, clearDestination: !destinationContainsInput, cancellationToken); } else { - // Recreate the destination as a fresh, non-link tree first so a pre-existing - // junction/symlink (or stale bytes) from a reused output directory can't redirect the - // copy or linger. - DeleteDirectoryTreeOrLink(destForInput); + // Recreate the destination as a fresh, non-link tree first (unless it holds an input) + // so a pre-existing junction/symlink (or stale bytes) from a reused output directory + // can't redirect the copy or linger. + if (!destinationContainsInput) + { + DeleteDirectoryTreeOrLink(destForInput); + } + CopyDirectoryRecursive(sourceInRoot, destForInput, cancellationToken); } @@ -385,7 +415,7 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect /// (the merged 'In' root) is skipped while staging so a source that /// contains the previous merged output does not snapshot it into the new destination. /// - private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, string? excludeSubtree, CancellationToken cancellationToken) + private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, string? excludeSubtree, bool clearDestination, CancellationToken cancellationToken) { string staging = Path.Combine(Path.GetTempPath(), "mtp-trx-merge-" + Guid.NewGuid().ToString("N")); try @@ -394,7 +424,14 @@ private static void CopyViaStaging(string sourceDirectory, string destinationDir // destination (which may be nested inside the source) cannot corrupt the source, and the // final copy never recurses into its own destination. CopyDirectoryRecursive(sourceDirectory, staging, excludeSubtree, cancellationToken); - DeleteDirectoryTreeOrLink(destinationDirectory); + + // Only clear the destination when it does not contain an input tree; otherwise overlay so + // originals are never removed (RFC 018 requires them to remain on disk). + if (clearDestination) + { + DeleteDirectoryTreeOrLink(destinationDirectory); + } + CopyDirectoryRecursive(staging, destinationDirectory, excludeSubtree: null, cancellationToken); } finally @@ -406,6 +443,20 @@ private static void CopyViaStaging(string sourceDirectory, string destinationDir } } + private static bool ContainsAnyDirectory(string candidateDirectory, IReadOnlyList directories) + { + string candidateFull = Path.GetFullPath(candidateDirectory); + foreach (string directory in directories) + { + if (IsUnderDirectory(Path.GetFullPath(directory), candidateFull)) + { + return true; + } + } + + return false; + } + private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) => CopyDirectoryRecursive(sourceDirectory, destinationDirectory, excludeSubtree: null, cancellationToken); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 93509f53bb..14eebf9c3e 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -404,6 +404,32 @@ public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_DoesNotAccumulat } } + [TestMethod] + public async Task MergeToFileAsync_WhenInputLivesUnderMergedRoot_PreservesOriginalReportAndAttachment() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The input report and its attachment live under the merged 'In' root (output beside them, + // runName 'run'). Relocation must never delete the originals (RFC 018 requires they remain). + string inputDir = Path.Combine(tempDirectory, "run", "In", "0"); + string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); + string output = Path.Combine(tempDirectory, "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + Assert.IsTrue(File.Exists(output)); + // Original report and original attachment must still exist. + Assert.IsTrue(File.Exists(input)); + Assert.IsTrue(File.Exists(Path.Combine(inputDir, "dep", "In", "machine", "log.txt"))); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static string WriteReportWithAttachment(string inputDirectory, string fileName, string deploymentRoot, string attachmentContent) { Directory.CreateDirectory(inputDirectory); From 92aae3a475a68961974e519e175f44e5f9ac36fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 16:45:02 +0200 Subject: [PATCH 11/26] Fix IDE0008 CI break + address review: relocation confinement, staging cleanup, docs - Fix the CI build break: use the explicit FileArtifactMessages type instead of var at the CreateFileArtifactMessages call site (IDE0008). - TRX relocation: derive a single confined deployment-root leaf from runName (used by both TestSettings and relocation) so a hostile '..' can't make downstream consumers resolve attachment hrefs outside the output directory; protect each input's resolved source 'In' root (not just the report dir) from wholesale deletion; make staging cleanup best-effort so it can't mask an in-flight cancellation. - CtrfReportMerger: update the to match the implemented tool/environment merge contract (neutral tool identity on disagreement; module-specific env fields dropped). - FileArtifactMessagesSerializer: document the new Kind field in the wire-layout comment. - Update the escape test to assert the confined deployment root. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 4 +- .../TrxReportEngine.Merge.cs | 70 ++++++++++++++----- .../DotnetTest/IPC/DotnetTestDataConsumer.cs | 2 +- .../FileArtifactMessagesSerializer.cs | 4 ++ .../TrxReportEngineMergeTests.cs | 22 +++--- 5 files changed, 74 insertions(+), 28 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index f11a7fe1ee..fa85977000 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -17,7 +17,9 @@ namespace Microsoft.Testing.Extensions.CtrfReport; /// /// results.tests[] arrays are concatenated as-is. /// results.summary counters are re-derived by counting the merged tests[] (so summary.tests always matches the array length); start/stop use the earliest/latest across inputs, duration is the resulting span. -/// reportFormat, specVersion, tool and environment are taken from the first report; reportId is freshly generated. +/// reportFormat and specVersion are taken from the first report; reportId is freshly generated. +/// tool keeps a concrete identity only when every input reported the exact same tool object; otherwise (inputs disagree or any input omits it) a neutral merger identity is used, so one framework is not attributed to another's tests. +/// environment keeps the first report's shared fields, but module-specific values under extra (testApplication, exitCode) are dropped rather than presented as describing all merged modules. /// /// internal static class CtrfReportMerger diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 94280c321e..bb318f595c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -217,14 +217,12 @@ internal static async Task MergeToFileAsync( private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, string runName, CancellationToken cancellationToken) { string outputFull = Path.GetFullPath(outputDirectory); - string mergedDeploymentRoot = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); + string mergedDeploymentRoot = GetConfinedDeploymentRootLeaf(runName); string mergedRootFull = Path.GetFullPath(Path.Combine(outputFull, mergedDeploymentRoot)); - // The sanitizer leaves '.' and '..' intact, so a hostile runName ('..') could point the merged - // deployment root outside the output directory. Reject anything that escapes lexically, and also - // reject when the deployment root (or any component of it below the output directory) is a - // reparse point — a symlink/junction there would resolve writes outside the confined root even - // though the lexical check passes. + // The confined leaf can no longer be '.' or '..', but keep the defensive lexical/reparse checks: + // reject if the deployment root escapes the output directory or any component below it is a + // reparse point (a symlink/junction there would resolve writes outside the confined root). if (!IsUnderDirectory(mergedRootFull, outputFull) || HasReparsePointComponent(mergedRootFull, outputFull)) { return; @@ -232,17 +230,33 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string mergedInRoot = Path.Combine(mergedRootFull, "In"); - // Absolute directories of every input report. A destination folder must never be wholesale- - // deleted when it contains one of these, otherwise relocation would remove the originals (which - // RFC 018 requires to stay on disk) or a sibling/later input's report and attachments. - var protectedInputDirectories = new List(inputPaths.Count); - foreach (string inputPath in inputPaths) + // Absolute directories that must never be wholesale-deleted by relocation: each input report's + // parent directory AND each input's resolved source 'In' root. Deleting a destination that + // contains one of these would remove the originals (which RFC 018 requires to stay on disk) or a + // sibling/later input's report and attachments. + var protectedDirectories = new List(inputPaths.Count * 2); + for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) { try { - if (Path.GetDirectoryName(Path.GetFullPath(inputPath)) is { Length: > 0 } dir) + if (Path.GetDirectoryName(Path.GetFullPath(inputPaths[i])) is not { Length: > 0 } inputDir) { - protectedInputDirectories.Add(dir); + continue; + } + + protectedDirectories.Add(inputDir); + + string? deploymentRoot = reports[i].Root is { } r + ? FindChild(r, "TestSettings")?.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, "Deployment", StringComparison.Ordinal))?.Attribute("runDeploymentRoot")?.Value + : null; + + if (!RoslynString.IsNullOrEmpty(deploymentRoot)) + { + string source = Path.GetFullPath(Path.Combine(inputDir, deploymentRoot, "In")); + if (IsUnderDirectory(source, inputDir)) + { + protectedDirectories.Add(source); + } } } catch (Exception ex) when (ex is ArgumentException or NotSupportedException) @@ -323,7 +337,7 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO // If it does (e.g. an input lives under the merged 'In' root), a blanket delete would // remove originals or sibling/later inputs, so we overlay the copy instead (per-file // overwrite; CopyDirectoryRecursive still removes stale destination reparse points). - bool destinationContainsInput = ContainsAnyDirectory(destForInput, protectedInputDirectories); + bool destinationContainsInput = ContainsAnyDirectory(destForInput, protectedDirectories); if (strictOverlap) { @@ -436,9 +450,17 @@ private static void CopyViaStaging(string sourceDirectory, string destinationDir } finally { - if (Directory.Exists(staging)) + // Best-effort cleanup: a failure here must not replace an in-flight OperationCanceledException + // (or the real merge exception) with an I/O/authorization error. + try + { + if (Directory.Exists(staging)) + { + Directory.Delete(staging, recursive: true); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - Directory.Delete(staging, recursive: true); } } } @@ -653,11 +675,23 @@ private static XElement BuildTestSettings(string runName) NamespaceUri + "TestSettings", new XAttribute("name", "default"), new XAttribute("id", Guid.NewGuid())); - string runDeploymentRoot = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); - testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", runDeploymentRoot))); + testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", GetConfinedDeploymentRootLeaf(runName)))); return testSettings; } + /// + /// Produces a single, confined deployment-root leaf from for use both in + /// the emitted TestSettings/Deployment/@runDeploymentRoot and in attachment relocation, so the + /// two always agree and neither can escape the output directory. The file-name sanitizer already + /// replaces path separators and reserved names, so the only residual escape values are . and + /// .., which are prefixed to keep the leaf confined. + /// + private static string GetConfinedDeploymentRootLeaf(string runName) + { + string leaf = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); + return leaf is "." or ".." ? "_" + leaf : leaf; + } + private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement runInfos, XElement collectorDataEntries) { var counters = new XElement(NamespaceUri + "Counters"); diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs index 3bdd938d8b..a362ed4eda 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/DotnetTestDataConsumer.cs @@ -175,7 +175,7 @@ public async Task ConsumeAsync(IDataProducer dataProducer, IData value, Cancella break; case SessionFileArtifact sessionFileArtifact: - var fileArtifactMessages = CreateFileArtifactMessages(_executionId, sessionFileArtifact); + FileArtifactMessages fileArtifactMessages = CreateFileArtifactMessages(_executionId, sessionFileArtifact); await _dotnetTestConnection.SendMessageAsync(fileArtifactMessages).ConfigureAwait(false); break; diff --git a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs index bef6b07175..71966552bf 100644 --- a/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs +++ b/src/Platform/Microsoft.Testing.Platform/ServerMode/DotnetTest/IPC/Serializers/FileArtifactMessagesSerializer.cs @@ -46,6 +46,10 @@ namespace Microsoft.Testing.Platform.IPC.Serializers; |---FileArtifactMessageList[0].SessionUid Id---| (2 bytes) |---FileArtifactMessageList[0].SessionUid Size---| (4 bytes) |---FileArtifactMessageList[0].SessionUid Value---| (n bytes) + + |---FileArtifactMessageList[0].Kind Id---| (2 bytes) + |---FileArtifactMessageList[0].Kind Size---| (4 bytes) + |---FileArtifactMessageList[0].Kind Value---| (n bytes) */ internal sealed class FileArtifactMessagesSerializer : NamedPipeSerializer, INamedPipeSerializer diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 14eebf9c3e..fe0b86eeea 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -289,7 +289,7 @@ public async Task MergeToFileAsync_IsolatesCollidingAttachmentsPerInputAndRewrit } [TestMethod] - public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_SkipsRelocation() + public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfinedDeploymentRoot() { string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDirectory); @@ -299,17 +299,23 @@ public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_SkipsReloca string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "out", "merged.trx"); - // A hostile runName of ".." would place the merged deployment root outside the output - // directory; relocation must refuse to write there but the merge itself must still succeed. - await TrxReportEngine.MergeToFileAsync([input, input], output, Guid.NewGuid(), "..", CancellationToken.None); + // A hostile runName of ".." must be confined to a safe leaf used consistently for both the + // emitted deployment root and attachment relocation, so nothing escapes the output directory. + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "..", CancellationToken.None); Assert.IsTrue(File.Exists(output)); - // The only physical attachment must remain the single source copy under the input tree — - // relocation must not have copied it anywhere (it was refused for escaping the output dir). + // The merged TRX must declare a confined deployment root (never ".."). + string? deploymentRoot = XDocument.Load(output).Descendants() + .FirstOrDefault(e => e.Name.LocalName == "Deployment")?.Attribute("runDeploymentRoot")?.Value; + Assert.AreEqual("_..", deploymentRoot); + + // Every physical attachment must remain under the output directory (never in its parent). List attachmentCopies = [.. Directory.GetFiles(tempDirectory, "log.txt", SearchOption.AllDirectories)]; - Assert.HasCount(1, attachmentCopies); - Assert.StartsWith(inputDir, attachmentCopies[0]); + foreach (string copy in attachmentCopies) + { + Assert.StartsWith(tempDirectory, copy); + } } finally { From 109ab51cdc5909f6e7bd0841ac2168a4e4c96978 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 17:42:18 +0200 Subject: [PATCH 12/26] Make report merges deterministic and non-destructive (RFC 018 review) Address a second round of review findings on the TRX/JUnit/CTRF mergers: - Determinism (idempotency): derive the TRX id from runId (was Guid.NewGuid) and the CTRF reportId from the inputs (was Guid.NewGuid), so identical inputs reproduce byte-identical output across orchestrator retries. - Read-only inputs: reject an output path that aliases one of the input report paths in all three mergers (TRX/JUnit/CTRF), so a truncating write can never overwrite one of its own sources. - Path-traversal hrefs: drop an attachment reference whose relative value climbs above the deployment root (e.g. '../../../secret') instead of emitting a traversal href a consumer could resolve outside the output tree. - TRX relocation is now genuinely non-destructive: it no longer infers ownership from path overlap (which could delete/omit legitimate source attachments on a first merge). It copies all source content and overlays, never deleting an original; the excludeSubtree/clearDestination machinery is removed. - Replace the empty staging-cleanup catch with an explanatory comment. Tests updated/added: determinism, output-aliasing rejection, href-traversal drop, and the nested-layout test now asserts preservation instead of an accumulation bound. 53 merge tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 59 +++++- .../JUnitReportMerger.cs | 24 +++ .../TrxReportEngine.Merge.cs | 181 ++++++++++++------ .../CtrfReportMergerTests.cs | 28 ++- .../JUnitReportMergerTests.cs | 22 +++ .../TrxReportEngineMergeTests.cs | 122 +++++++++++- 6 files changed, 367 insertions(+), 69 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index fa85977000..b566461670 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -17,7 +17,7 @@ namespace Microsoft.Testing.Extensions.CtrfReport; /// /// results.tests[] arrays are concatenated as-is. /// results.summary counters are re-derived by counting the merged tests[] (so summary.tests always matches the array length); start/stop use the earliest/latest across inputs, duration is the resulting span. -/// reportFormat and specVersion are taken from the first report; reportId is freshly generated. +/// reportFormat and specVersion are taken from the first report; reportId is derived deterministically from the inputs, so identical inputs reproduce the same id (RFC 018 idempotency). /// tool keeps a concrete identity only when every input reported the exact same tool object; otherwise (inputs disagree or any input omits it) a neutral merger identity is used, so one framework is not attributed to another's tests. /// environment keeps the first report's shared fields, but module-specific values under extra (testApplication, exitCode) are dropped rather than presented as describing all merged modules. /// @@ -196,7 +196,7 @@ internal static string Merge(IReadOnlyList inputReports) { ["reportFormat"] = first["reportFormat"]?.DeepClone() ?? "CTRF", ["specVersion"] = first["specVersion"]?.DeepClone() ?? "0.0.0", - ["reportId"] = Guid.NewGuid().ToString("D"), + ["reportId"] = CreateDeterministicReportId(inputReports), ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stopMs).ToString("O", CultureInfo.InvariantCulture), ["results"] = resultsObject, }; @@ -224,6 +224,10 @@ internal static async Task MergeToFileAsync( throw new ArgumentNullException(nameof(outputPath)); } + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an + // output that aliases an input so a merge can never overwrite one of its own sources. + EnsureOutputDoesNotAliasInput(inputPaths, outputPath); + var reports = new List(inputPaths.Count); foreach (string inputPath in inputPaths) { @@ -274,6 +278,57 @@ private static bool TryReadLong(JsonNode summary, string propertyName, out long return false; } + /// + /// Derives a stable reportId from the raw input reports so identical inputs reproduce the same + /// id on every retry (RFC 018 idempotency) without a random source or reusing an input report's id. + /// A non-cryptographic 128-bit FNV-1a fill is sufficient here — the id only needs to be deterministic + /// and collision-resistant enough to identify a merged report, not secret. + /// + private static string CreateDeterministicReportId(IReadOnlyList inputReports) + { + const ulong fnvPrime = 1099511628211UL; + ulong hashLow = 14695981039346656037UL; + ulong hashHigh = 0x9E3779B97F4A7C15UL; + + foreach (string report in inputReports) + { + foreach (char c in report) + { + hashLow = (hashLow ^ c) * fnvPrime; + hashHigh = (hashHigh ^ c) * fnvPrime; + } + + // Fold in each input's length so different chunk boundaries (e.g. ["ab","c"] vs ["a","bc"]) + // never collide. + hashLow = (hashLow ^ (ulong)report.Length) * fnvPrime; + hashHigh = (hashHigh ^ ((ulong)report.Length + 1UL)) * fnvPrime; + } + + var bytes = new byte[16]; + BitConverter.GetBytes(hashLow).CopyTo(bytes, 0); + BitConverter.GetBytes(hashHigh).CopyTo(bytes, 8); + return new Guid(bytes).ToString("D"); + } + + /// + /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites + /// a source report (RFC 018 keeps inputs on disk, read-only). + /// + private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) + { + StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string outputFull = Path.GetFullPath(outputPath); + foreach (string inputPath in inputPaths) + { + if (string.Equals(Path.GetFullPath(inputPath), outputFull, comparison)) + { + throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); + } + } + } + private static long Min(long? current, long candidate) => current is null || candidate < current ? candidate : current.Value; diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index e6271362b5..716cba0d08 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -121,6 +121,11 @@ internal static async Task MergeToFileAsync( throw new ArgumentNullException(nameof(outputPath)); } + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an + // output that aliases an input so a merge (which writes with a truncating File.Create) can never + // overwrite one of its own sources. + EnsureOutputDoesNotAliasInput(inputPaths, outputPath); + var reports = new List(inputPaths.Count); foreach (string inputPath in inputPaths) { @@ -166,4 +171,23 @@ private static bool TryReadTimestamp(XElement element, string attributeName, out return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result); } + + /// + /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites + /// a source report (RFC 018 keeps inputs on disk, read-only). + /// + private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) + { + StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + string outputFull = Path.GetFullPath(outputPath); + foreach (string inputPath in inputPaths) + { + if (string.Equals(Path.GetFullPath(inputPath), outputFull, comparison)) + { + throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); + } + } + } } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index bb318f595c..98a758e65c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -14,7 +14,9 @@ internal sealed partial class TrxReportEngine /// This is a pure, invocation-agnostic XML-level merge: it does no I/O and reads no clock, so a /// user-facing merge tool and an SDK-orchestrated post-processor can share it and, given the same /// / and inputs, produce equivalent output. The - /// only non-deterministic element is the freshly generated TestSettings id. + /// emitted TestSettings id is derived deterministically from , so + /// identical inputs reproduce byte-for-byte identical XML (RFC 018 idempotency; the orchestrator may + /// retry). /// /// Merge rules: /// @@ -142,7 +144,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI new XAttribute("name", runName)); mergedTestRun.Add(BuildTimes(earliestStart, latestFinish)); - mergedTestRun.Add(BuildTestSettings(runName)); + mergedTestRun.Add(BuildTestSettings(runId, runName)); mergedTestRun.Add(mergedResults); mergedTestRun.Add(mergedTestDefinitions); mergedTestRun.Add(mergedTestEntries); @@ -172,6 +174,11 @@ internal static async Task MergeToFileAsync( throw new ArgumentNullException(nameof(outputPath)); } + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk. The merged + // TRX is written with File.Create (truncating); reject an output that aliases an input so a merge + // can never overwrite one of its own sources. + EnsureOutputDoesNotAliasInput(inputPaths, outputPath); + var reports = new List(inputPaths.Count); foreach (string inputPath in inputPaths) { @@ -323,39 +330,31 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string prefix = i.ToString(CultureInfo.InvariantCulture); string destForInput = Path.Combine(mergedInRoot, prefix); - // Strict overlap (one 'In' root is nested inside the other): copying source directly - // into a subfolder of the merged root would either recurse into its own destination - // (merged nested under source) or, if we simply skipped, leave the prefixed hrefs - // dangling. Stage the copy through a temporary directory outside both trees, then move - // it into place, so the per-input prefix rewrite below is always correct. + // Strict overlap (one 'In' root is nested inside the other, e.g. the output directory was + // placed inside an input's deployment tree): copying source directly into a subfolder of + // the merged root would recurse into its own destination. Stage the copy through a + // temporary directory outside both trees, then overlay it into place, so the per-input + // prefix rewrite below is always correct without recursing. bool strictOverlap = IsUnderDirectory(mergedInRoot, sourceInRoot) || IsUnderDirectory(sourceInRoot, mergedInRoot); - // Isolate each input under its own subfolder so identical relative attachment paths - // from different inputs cannot shadow each other. - // - // Only clear the destination wholesale when it does not contain an input report tree. - // If it does (e.g. an input lives under the merged 'In' root), a blanket delete would - // remove originals or sibling/later inputs, so we overlay the copy instead (per-file - // overwrite; CopyDirectoryRecursive still removes stale destination reparse points). - bool destinationContainsInput = ContainsAnyDirectory(destForInput, protectedDirectories); - if (strictOverlap) { - // Stage the source out first (before clearing the destination, which could otherwise - // be inside the source tree). Only when the merged root is nested INSIDE the source - // does the source snapshot also contain the previous merged 'In' tree; exclude it in - // that direction so repeated merges don't accumulate recursively-nested stale trees. - string? excludeSubtree = IsUnderDirectory(mergedInRoot, sourceInRoot) && !string.Equals(mergedInRoot, sourceInRoot, PathComparison) - ? mergedInRoot - : null; - CopyViaStaging(sourceInRoot, destForInput, excludeSubtree, clearDestination: !destinationContainsInput, cancellationToken); + // Non-destructive: never delete or omit any part of the source tree (RFC 018 treats + // inputs as read-only and requires them to stay on disk). We deliberately do NOT try + // to infer which files under an overlapping path are the merger's own prior output — + // that inference could drop legitimate source attachments — so we copy everything and + // overlay (per-file overwrite). In the pathological nested-output configuration this + // may leave benign stale bytes from an earlier retry, which is preferred over ever + // removing an original. + CopyViaStaging(sourceInRoot, destForInput, cancellationToken); } else { - // Recreate the destination as a fresh, non-link tree first (unless it holds an input) - // so a pre-existing junction/symlink (or stale bytes) from a reused output directory - // can't redirect the copy or linger. - if (!destinationContainsInput) + // Disjoint trees: the destination lives under the merged 'In' root, outside every + // source tree, so recreating it as a fresh, non-link tree is safe and clears stale + // bytes / redirecting junctions from a reused output directory. Guard only against a + // destination that happens to hold an input report or its resolved source root. + if (!ContainsAnyDirectory(destForInput, protectedDirectories)) { DeleteDirectoryTreeOrLink(destForInput); } @@ -383,7 +382,9 @@ private static void RewriteAttachmentHrefs(XDocument report, string prefix) // Attachment references are relative to the deployment root: UriAttachment '' // and per-result ''. Prefix them so they point at the isolated subfolder. - foreach (XElement element in root.Descendants()) + // Materialize first: an escaping reference is removed below, and removing while enumerating the + // lazy Descendants() sequence would be unsafe. + foreach (XElement element in root.Descendants().ToList()) { switch (element.Name.LocalName) { @@ -406,9 +407,50 @@ private static void PrefixRelativeAttribute(XElement element, string attributeNa return; } + // A relative value such as '../../../secret' passes the rooted check yet, once resolved from the + // merged deployment root, escapes the confined output tree. Drop the reference entirely rather + // than emit a traversal href a downstream TRX consumer could resolve outside the output. + if (EscapesRoot(attribute.Value)) + { + element.Remove(); + return; + } + attribute.Value = prefix + "/" + attribute.Value; } + /// + /// Returns if the relative resolves above its + /// own root (i.e. a .. segment pops past the start), meaning it would escape the confined + /// deployment directory once resolved. + /// + private static bool EscapesRoot(string relativePath) + { + int depth = 0; + foreach (string segment in relativePath.Split('/', '\\')) + { + if (segment is "" or ".") + { + continue; + } + + if (segment == "..") + { + depth--; + if (depth < 0) + { + return true; + } + } + else + { + depth++; + } + } + + return false; + } + private static bool IsUnderDirectory(string candidateFullPath, string rootDirectory) { string rootFull = Path.GetFullPath(rootDirectory); @@ -425,28 +467,19 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect /// Copies to via a /// temporary staging directory outside both trees. Used when the source and the merged destination /// strictly overlap, so the copy never recurses into its own destination while still landing the - /// files at the prefixed destination (keeping the rewritten hrefs valid). - /// (the merged 'In' root) is skipped while staging so a source that - /// contains the previous merged output does not snapshot it into the new destination. + /// files at the prefixed destination (keeping the rewritten hrefs valid). The copy is non-destructive: + /// nothing under the source is deleted or omitted (RFC 018 keeps inputs on disk, read-only); the + /// destination is overlaid per file. /// - private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, string? excludeSubtree, bool clearDestination, CancellationToken cancellationToken) + private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) { string staging = Path.Combine(Path.GetTempPath(), "mtp-trx-merge-" + Guid.NewGuid().ToString("N")); try { - // Copy the source out to a temp location OUTSIDE both trees first, so clearing the - // destination (which may be nested inside the source) cannot corrupt the source, and the - // final copy never recurses into its own destination. - CopyDirectoryRecursive(sourceDirectory, staging, excludeSubtree, cancellationToken); - - // Only clear the destination when it does not contain an input tree; otherwise overlay so - // originals are never removed (RFC 018 requires them to remain on disk). - if (clearDestination) - { - DeleteDirectoryTreeOrLink(destinationDirectory); - } - - CopyDirectoryRecursive(staging, destinationDirectory, excludeSubtree: null, cancellationToken); + // Snapshot the source to a temp location OUTSIDE both trees first, so the final copy into a + // destination that may be nested inside the source never recurses into itself. + CopyDirectoryRecursive(sourceDirectory, staging, cancellationToken); + CopyDirectoryRecursive(staging, destinationDirectory, cancellationToken); } finally { @@ -461,6 +494,8 @@ private static void CopyViaStaging(string sourceDirectory, string destinationDir } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + // Swallowed on purpose: the staging directory lives under the temp path and leaking it is + // preferable to masking the primary exception that is already unwinding. } } } @@ -480,19 +515,9 @@ private static bool ContainsAnyDirectory(string candidateDirectory, IReadOnlyLis } private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) - => CopyDirectoryRecursive(sourceDirectory, destinationDirectory, excludeSubtree: null, cancellationToken); - - private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, string? excludeSubtree, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); - // Skip the excluded subtree (e.g. a previous merged 'In' root nested under the source) so we - // don't snapshot the merger's own prior output into the new destination. - if (excludeSubtree is not null && IsUnderDirectory(Path.GetFullPath(sourceDirectory), excludeSubtree)) - { - return; - } - // Do not descend into reparse points (symlinks/junctions): a link inside the (confined) source // tree could otherwise redirect the copy to an arbitrary location. if (IsReparsePoint(sourceDirectory)) @@ -536,7 +561,7 @@ private static void CopyDirectoryRecursive(string sourceDirectory, string destin foreach (string directory in Directory.GetDirectories(sourceDirectory)) { - CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), excludeSubtree, cancellationToken); + CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), cancellationToken); } } @@ -669,16 +694,54 @@ private static XElement BuildTimes(DateTimeOffset? earliestStart, DateTimeOffset return times; } - private static XElement BuildTestSettings(string runName) + private static XElement BuildTestSettings(Guid runId, string runName) { var testSettings = new XElement( NamespaceUri + "TestSettings", new XAttribute("name", "default"), - new XAttribute("id", Guid.NewGuid())); + new XAttribute("id", CreateDeterministicSettingsId(runId))); testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", GetConfinedDeploymentRootLeaf(runName)))); return testSettings; } + // Fixed namespace used to derive the (deterministic) TestSettings id from the run id, so the settings + // id is stable across retries yet distinct from the run id itself. + private static readonly Guid s_testSettingsIdNamespace = new("b3f8f9d1-2e4a-4c6b-9f0d-7a1c2e5b8d40"); + + /// + /// Derives a stable TestSettings id from by XoR-ing it with a fixed + /// namespace, so identical inputs produce identical merged XML (RFC 018 idempotency) without emitting + /// the run id verbatim as the settings id. + /// + private static Guid CreateDeterministicSettingsId(Guid runId) + { + byte[] runBytes = runId.ToByteArray(); + byte[] namespaceBytes = s_testSettingsIdNamespace.ToByteArray(); + var result = new byte[16]; + for (int i = 0; i < result.Length; i++) + { + result[i] = (byte)(runBytes[i] ^ namespaceBytes[i]); + } + + return new Guid(result); + } + + /// + /// Rejects an output path that resolves to one of the input report paths. RFC 018 treats per-module + /// inputs as read-only and requires them to remain on disk, so a merge must never overwrite a source. + /// + private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) + { + string outputFull = Path.GetFullPath(outputPath); + foreach (string inputPath in inputPaths) + { + if (string.Equals(Path.GetFullPath(inputPath), outputFull, PathComparison)) + { + throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); + } + } + } + /// /// Produces a single, confined deployment-root leaf from for use both in /// the emitted TestSettings/Deployment/@runDeploymentRoot and in attachment relocation, so the diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index d5a3825ebb..29df6d2c9b 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -144,16 +144,42 @@ public void Merge_DropsModuleSpecificEnvironmentExtraFields() } [TestMethod] - public void Merge_GeneratesFreshReportId() + public void Merge_DerivesDeterministicReportIdNotReusingInput() { string a = BuildReport(); string b = BuildReport(); string? idA = (string?)JsonNode.Parse(a)!["reportId"]; string? mergedId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; + string? mergedIdAgain = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; Assert.IsNotNull(mergedId); + // Not one of the inputs' ids... Assert.AreNotEqual(idA, mergedId); + // ...and deterministic: identical inputs reproduce the same id on every merge (RFC 018 idempotency). + Assert.AreEqual(mergedId, mergedIdAgain); + } + + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string input = Path.Combine(tempDirectory, "a.json"); + File.WriteAllText(input, BuildReport()); + + // Overwriting an input would destroy a read-only source; it must be rejected. + await Assert.ThrowsExactlyAsync( + () => CtrfReportMerger.MergeToFileAsync([input], input, CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } } [TestMethod] diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs index b410626710..2abee9f91b 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs @@ -121,6 +121,28 @@ public async Task MergeToFileAsync_WritesMergedFileToDisk() } } + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string input = Path.Combine(tempDirectory, "a.xml"); + BuildReport(tests: 2).Save(input); + + // Overwriting an input would destroy a read-only source; it must be rejected. + await Assert.ThrowsExactlyAsync( + () => JUnitReportMerger.MergeToFileAsync([input], input, "run", CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static XElement Suite( string name, long tests = 1, diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index fe0b86eeea..dbc4fb8aed 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -288,6 +288,103 @@ public async Task MergeToFileAsync_IsolatesCollidingAttachmentsPerInputAndRewrit } } + [TestMethod] + public void Merge_WithIdenticalInputs_ProducesByteIdenticalXml() + { + var runId = Guid.NewGuid(); + + // RFC 018 requires the merge to be idempotent: identical inputs, runId and runName must reproduce + // identical output (the emitted TestSettings id is derived deterministically from runId). + string first = TrxReportEngine.Merge([BuildReport(), BuildReport()], runId, "run").ToString(); + string second = TrxReportEngine.Merge([BuildReport(), BuildReport()], runId, "run").ToString(); + + Assert.AreEqual(first, second); + } + + [TestMethod] + public void Merge_DerivesDeterministicTestSettingsIdDistinctFromRunId() + { + var runId = Guid.NewGuid(); + + XDocument merged = TrxReportEngine.Merge([BuildReport()], runId, "run"); + string? settingsId = merged.Descendants().FirstOrDefault(e => e.Name.LocalName == "TestSettings")?.Attribute("id")?.Value; + + Assert.IsNotNull(settingsId); + // Deterministic yet not the run id verbatim. + Assert.AreNotEqual(runId.ToString(), settingsId); + Assert.IsTrue(Guid.TryParse(settingsId, out _)); + } + + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string input = Path.Combine(tempDirectory, "a.trx"); + BuildReport().Save(input); + + // Writing the merged output over an input would destroy a read-only source; it must be rejected. + await Assert.ThrowsExactlyAsync( + () => TrxReportEngine.MergeToFileAsync([input], input, Guid.NewGuid(), "run", CancellationToken.None)); + + // The input must be left untouched. + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenAttachmentHrefEscapesRoot_DropsTheReference() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(inputDir); + + // A hostile relative href that climbs above the deployment root. Even though it is not rooted, + // resolving it from the merged deployment directory would escape the output tree, so the merge + // must drop the reference rather than emit a traversal href. + string attachmentDirectory = Path.Combine(inputDir, "dep", "In", "machine"); + Directory.CreateDirectory(attachmentDirectory); + File.WriteAllText(Path.Combine(attachmentDirectory, "log.txt"), "AAA"); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "../../../secret")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + [TestMethod] public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfinedDeploymentRoot() { @@ -380,16 +477,17 @@ public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndK } [TestMethod] - public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_DoesNotAccumulateNestedTrees() + public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_NeverDeletesOriginalSource() { string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDirectory); try { // Layout where the merged deployment root is nested strictly INSIDE the input's source 'In' - // tree, so staging the whole source would capture the previous merged output. Merging - // repeatedly into the same output must not snapshot the prior merged tree into the new one - // (which would grow "0/0/0/..." without bound). + // tree. Relocation is deliberately non-destructive: it never infers ownership from path + // overlap and never deletes or omits any source bytes (RFC 018 keeps inputs read-only). It + // may leave benign stale copies from earlier retries, which is preferred over ever removing + // an original — so we assert preservation and resolvability, not a bounded copy count. string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "dep", "In", "out", "merged.trx"); @@ -400,9 +498,19 @@ public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_DoesNotAccumulat Assert.IsTrue(File.Exists(output)); - // Original source attachment + a single relocated copy — bounded, not one-per-run. - List copies = [.. Directory.GetFiles(tempDirectory, "log.txt", SearchOption.AllDirectories)]; - Assert.HasCount(2, copies); + // The original source report and attachment must survive every repeated merge untouched. + Assert.IsTrue(File.Exists(input)); + string originalAttachment = Path.Combine(tempDirectory, "dep", "In", "machine", "log.txt"); + Assert.IsTrue(File.Exists(originalAttachment)); + Assert.AreEqual("AAA", File.ReadAllText(originalAttachment)); + + // The merged report's rewritten href must resolve to a real relocated copy under the merged + // deployment root ('/run/In'). + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + string resolved = Path.Combine(tempDirectory, "dep", "In", "out", "run", "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); } finally { From df5c7c6c853b02e50cfff7f0a1ffb34dd5ed87bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 18:29:34 +0200 Subject: [PATCH 13/26] Fix TRX per-test ResultFile relocation, naming, and IDE0008 (RFC 018 review) - ResultFile relocation: TRX consumers resolve per-test result files under In//, so prefix the owning relativeResultsDirectory (once) instead of the ResultFile @path, which was leaving merged per-test attachments dangling. Collector (deployment- root relative) still prefixes the href; a standalone ResultFile with no owning result is treated like a href. - Traversal: validate the combined relativeResultsDirectory/path and drop any escaping (or rooted) reference, so a hostile relativeResultsDirectory such as '../../..' cannot make the merged TRX point outside its deployment root. - Rename the static readonly namespace field to PascalCase (editorconfig). - Use explicit types for the deterministic-id byte buffers (IDE0008). Tests added: per-test ResultFile directory-prefix relocation and escaping relativeResultsDirectory drop. 55 merge tests pass. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 2 +- .../TrxReportEngine.Merge.cs | 96 ++++++++++++++++--- .../TrxReportEngineMergeTests.cs | 96 +++++++++++++++++++ 3 files changed, 178 insertions(+), 16 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index b566461670..6de7af0a89 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -304,7 +304,7 @@ private static string CreateDeterministicReportId(IReadOnlyList inputRep hashHigh = (hashHigh ^ ((ulong)report.Length + 1UL)) * fnvPrime; } - var bytes = new byte[16]; + byte[] bytes = new byte[16]; BitConverter.GetBytes(hashLow).CopyTo(bytes, 0); BitConverter.GetBytes(hashHigh).CopyTo(bytes, 8); return new Guid(bytes).ToString("D"); diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 98a758e65c..aaf3724f1c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -380,23 +380,89 @@ private static void RewriteAttachmentHrefs(XDocument report, string prefix) return; } - // Attachment references are relative to the deployment root: UriAttachment '' - // and per-result ''. Prefix them so they point at the isolated subfolder. - // Materialize first: an escaping reference is removed below, and removing while enumerating the - // lazy Descendants() sequence would be unsafe. - foreach (XElement element in root.Descendants().ToList()) + // Attachment references come in two shapes with different resolution rules: + // * Collector attachments '' are relative to the deployment 'In' root, so the + // copy under In//... is reached by prefixing the href. + // * Per-test '' are resolved by consumers under + // In//, so the copy under + // In/// is reached by prefixing the *directory*, not + // the path. A ResultFile without an owning relativeResultsDirectory behaves like a collector + // href (In/). + // Materialize each pass first: an escaping reference is removed, and removing while enumerating + // the lazy Descendants() sequence would be unsafe. + foreach (XElement collectorAttachment in root.Descendants().Where(e => e.Name.LocalName == "A").ToList()) { - switch (element.Name.LocalName) + PrefixRelativeAttribute(collectorAttachment, "href", prefix); + } + + foreach (XElement unitTestResult in root.Descendants().Where(e => e.Name.LocalName == "UnitTestResult").ToList()) + { + PrefixResultFilesForUnitTestResult(unitTestResult, prefix); + } + + // Any standalone ResultFile (no owning UnitTestResult, e.g. from a foreign producer) resolves at + // In/, like a collector href. Materialized after the pass above so ResultFiles already + // dropped there do not reappear here. + foreach (XElement standaloneResultFile in root.Descendants() + .Where(e => e.Name.LocalName == "ResultFile" && e.Ancestors().All(a => a.Name.LocalName != "UnitTestResult")) + .ToList()) + { + PrefixRelativeAttribute(standaloneResultFile, "path", prefix); + } + } + + /// + /// Prefixes the per-test ResultFile references of a single UnitTestResult. Consumers + /// resolve them under In/<relativeResultsDirectory>/<path>, so the relocated copy + /// (under In/<prefix>/<relativeResultsDirectory>/<path>) is reached by + /// prefixing the directory once; a reference whose combined directory/path escapes the root (or is + /// rooted) is dropped instead of emitted. + /// + private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, string prefix) + { + List resultFiles = [.. unitTestResult.Descendants().Where(e => e.Name.LocalName == "ResultFile")]; + if (resultFiles.Count == 0) + { + return; + } + + XAttribute? relativeDirectory = unitTestResult.Attribute("relativeResultsDirectory"); + string relativeDirectoryValue = relativeDirectory?.Value ?? string.Empty; + + if (relativeDirectory is null || RoslynString.IsNullOrEmpty(relativeDirectoryValue)) + { + // No owning directory: each path resolves at In/, like a collector href. + foreach (XElement resultFile in resultFiles) { - case "A": - PrefixRelativeAttribute(element, "href", prefix); - break; + PrefixRelativeAttribute(resultFile, "path", prefix); + } - case "ResultFile": - PrefixRelativeAttribute(element, "path", prefix); - break; + return; + } + + // Validate the combined directory/path (that is what a consumer resolves) and drop any escaping + // or rooted reference, so a hostile relativeResultsDirectory such as '../../..' cannot make the + // merged TRX point outside its deployment root. + bool anyKept = false; + foreach (XElement resultFile in resultFiles) + { + string path = resultFile.Attribute("path")?.Value ?? string.Empty; + if (Path.IsPathRooted(relativeDirectoryValue) || Path.IsPathRooted(path) || EscapesRoot(relativeDirectoryValue + "/" + path)) + { + resultFile.Remove(); + } + else + { + anyKept = true; } } + + // Prefix the directory once (only if at least one reference survived) so every kept path resolves + // to the relocated copy. + if (anyKept) + { + relativeDirectory.Value = prefix + "/" + relativeDirectoryValue; + } } private static void PrefixRelativeAttribute(XElement element, string attributeName, string prefix) @@ -706,7 +772,7 @@ private static XElement BuildTestSettings(Guid runId, string runName) // Fixed namespace used to derive the (deterministic) TestSettings id from the run id, so the settings // id is stable across retries yet distinct from the run id itself. - private static readonly Guid s_testSettingsIdNamespace = new("b3f8f9d1-2e4a-4c6b-9f0d-7a1c2e5b8d40"); + private static readonly Guid TestSettingsIdNamespace = new("b3f8f9d1-2e4a-4c6b-9f0d-7a1c2e5b8d40"); /// /// Derives a stable TestSettings id from by XoR-ing it with a fixed @@ -716,8 +782,8 @@ private static XElement BuildTestSettings(Guid runId, string runName) private static Guid CreateDeterministicSettingsId(Guid runId) { byte[] runBytes = runId.ToByteArray(); - byte[] namespaceBytes = s_testSettingsIdNamespace.ToByteArray(); - var result = new byte[16]; + byte[] namespaceBytes = TestSettingsIdNamespace.ToByteArray(); + byte[] result = new byte[16]; for (int i = 0; i < result.Length; i++) { result[i] = (byte)(runBytes[i] ^ namespaceBytes[i]); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index dbc4fb8aed..80c300bd46 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -385,6 +385,102 @@ public async Task MergeToFileAsync_WhenAttachmentHrefEscapesRoot_DropsTheReferen } } + [TestMethod] + public async Task MergeToFileAsync_RelocatesPerTestResultFilesByPrefixingResultDirectory() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // Per-test ResultFiles resolve under In//. The physical file + // lives at /In///, and the ResultFile + // path is '/'. Relocation must prefix the directory (not the path) so the + // merged href resolves to the copied bytes. + string inputDir = Path.Combine(tempDirectory, "in"); + const string executionId = "exec-1111"; + string physical = Path.Combine(inputDir, "dep", "In", executionId, "machine"); + Directory.CreateDirectory(physical); + File.WriteAllText(Path.Combine(physical, "log.txt"), "AAA"); + + var unitTestResult = new XElement( + Ns + "UnitTestResult", + new XAttribute("executionId", executionId), + new XAttribute("relativeResultsDirectory", executionId), + new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", "machine/log.txt")))); + + XDocument report = BuildReport(results: [unitTestResult]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + XDocument mergedDoc = XDocument.Load(output); + XElement mergedResult = mergedDoc.Descendants().First(e => e.Name.LocalName == "UnitTestResult"); + string relativeDirectory = mergedResult.Attribute("relativeResultsDirectory")!.Value; + string resultFilePath = mergedResult.Descendants().First(e => e.Name.LocalName == "ResultFile").Attribute("path")!.Value; + + // The directory is prefixed with the per-input isolation folder; the path is left intact. + Assert.AreEqual($"0/{executionId}", relativeDirectory); + Assert.AreEqual("machine/log.txt", resultFilePath); + + // The consumer-resolved path (In//) must point at real bytes. + string resolved = Path.Combine(tempDirectory, "out", "run", "In", relativeDirectory.Replace('/', Path.DirectorySeparatorChar), resultFilePath.Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + [TestMethod] + public async Task MergeToFileAsync_WhenResultDirectoryEscapesRoot_DropsResultFile() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // A hostile relativeResultsDirectory with a benign path: consumers resolve + // In//, which escapes. The reference must be dropped. + string inputDir = Path.Combine(tempDirectory, "in"); + string physical = Path.Combine(inputDir, "dep", "In", "machine"); + Directory.CreateDirectory(physical); + File.WriteAllText(Path.Combine(physical, "log.txt"), "AAA"); + + var unitTestResult = new XElement( + Ns + "UnitTestResult", + new XAttribute("executionId", "exec-1"), + new XAttribute("relativeResultsDirectory", "../../.."), + new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", "machine/log.txt")))); + + XDocument report = BuildReport(results: [unitTestResult]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List resultFilePaths = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "ResultFile").Select(e => e.Attribute("path")!.Value)]; + Assert.IsEmpty(resultFilePaths); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + [TestMethod] public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfinedDeploymentRoot() { From c14a6f9fe7d94c0355e1602850e2104b4e100209 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 18:35:51 +0200 Subject: [PATCH 14/26] Fix IDE0007 in merge test (use var for apparent type) The extensions test project enforces IDE0007 (use var when the type is apparent); XDocument.Load(...) is a factory whose type is apparent, so use var. Full solution build is clean (0 warnings, 0 errors). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxReportEngineMergeTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 80c300bd46..9a7e207b21 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -420,7 +420,7 @@ public async Task MergeToFileAsync_RelocatesPerTestResultFilesByPrefixingResultD await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); - XDocument mergedDoc = XDocument.Load(output); + var mergedDoc = XDocument.Load(output); XElement mergedResult = mergedDoc.Descendants().First(e => e.Name.LocalName == "UnitTestResult"); string relativeDirectory = mergedResult.Attribute("relativeResultsDirectory")!.Value; string resultFilePath = mergedResult.Descendants().First(e => e.Name.LocalName == "ResultFile").Attribute("path")!.Value; From 02c0befc5159f51a51d23f4f9b91df92e24fd0b6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 19:00:24 +0200 Subject: [PATCH 15/26] Harden report-merge filesystem handling (RFC 018 review) - Wire-id stability: pin FileArtifactMessageFieldsId.Kind == 7 (and SessionUid == 6) via a reflection raw-constant assertion, mirroring the FailedTestResult id test, so a renumbering is caught even though round-trip tests share the constant. - Cross-platform path aliasing: compare paths case-insensitively on every platform (Windows and the default macOS volume are case-insensitive) for the equal-roots, overlap and output-alias guards, instead of guessing case sensitivity from the OS name; over-detection is only ever more conservative. - Output aliasing: write each merged report to a temporary sibling and replace the destination entry, so a symlink/hardlink output alias of an input (undetectable by Path.GetFullPath) has only its link removed rather than the read-only source truncated in place. Applied to TRX/JUnit/CTRF. - Dangling references: verify each rewritten TRX attachment against the relocated tree and drop any reference whose target was not materialized (skipped source symlink or partial copy), so the merged TRX never carries a dangling href. Tests: Kind stability, dangling-reference drop. 56 merge + 17 serializer tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 66 ++++++++-- .../JUnitReportMerger.cs | 71 ++++++++-- .../TrxReportEngine.Merge.cs | 122 ++++++++++++++---- .../TrxReportEngineMergeTests.cs | 42 ++++++ .../DotnetTestProtocolSerializerTests.cs | 15 +++ 5 files changed, 276 insertions(+), 40 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 6de7af0a89..93fb2e839a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -247,12 +247,63 @@ internal static async Task MergeToFileAsync( Directory.CreateDirectory(outputDirectory); } + // Write to a temporary sibling, then replace the destination ENTRY. If the output path is a + // symlink/hardlink alias of an input (which the textual alias check above cannot detect because + // Path.GetFullPath does not resolve links), replacing the entry removes only the link and leaves + // the read-only source intact, rather than truncating it in place via WriteAllText. + string tempPath = GetTempSiblingPath(outputPath); + try + { #if NETCOREAPP - await File.WriteAllTextAsync(outputPath, merged, cancellationToken).ConfigureAwait(false); + await File.WriteAllTextAsync(tempPath, merged, cancellationToken).ConfigureAwait(false); #else - File.WriteAllText(outputPath, merged); - await Task.CompletedTask.ConfigureAwait(false); + File.WriteAllText(tempPath, merged); + await Task.CompletedTask.ConfigureAwait(false); #endif + ReplaceFile(tempPath, outputPath); + } + finally + { + TryDeleteFile(tempPath); + } + } + + private static string GetTempSiblingPath(string outputPath) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir + ? dir + : Directory.GetCurrentDirectory(); + return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + } + + private static void ReplaceFile(string tempPath, string outputPath) + { + // Delete the destination entry (a regular file, or a symlink/hardlink alias) before moving the + // freshly written temp file into place. Deleting a link removes only the link, never its target's + // content, so a source aliased by the output path is never truncated. An exact (case-insensitive) + // alias of an input has already been rejected, so this cannot delete an input in place. + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + + File.Move(tempPath, outputPath); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary + // exception (or the successful result) with a cleanup failure. + } } private static bool TryReadLong(JsonNode summary, string propertyName, out long value) @@ -312,17 +363,16 @@ private static string CreateDeterministicReportId(IReadOnlyList inputRep /// /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites - /// a source report (RFC 018 keeps inputs on disk, read-only). + /// a source report (RFC 018 keeps inputs on disk, read-only). Compares case-insensitively on every + /// platform: Windows and the default macOS volume are case-insensitive, and treating a case-differing + /// path as an alias only makes this guard more conservative on a case-sensitive volume. /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { - StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; string outputFull = Path.GetFullPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(Path.GetFullPath(inputPath), outputFull, comparison)) + if (string.Equals(Path.GetFullPath(inputPath), outputFull, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index 716cba0d08..8c390fe90d 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -141,13 +141,67 @@ internal static async Task MergeToFileAsync( Directory.CreateDirectory(outputDirectory); } - using FileStream stream = File.Create(outputPath); + // Write to a temporary sibling, then replace the destination ENTRY. If the output path is a + // symlink/hardlink alias of an input (which the textual alias check above cannot detect because + // Path.GetFullPath does not resolve links), replacing the entry removes only the link and leaves + // the read-only source intact, rather than truncating it in place via File.Create. + string tempPath = GetTempSiblingPath(outputPath); + try + { + using (FileStream stream = File.Create(tempPath)) + { #if NETCOREAPP - await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); + await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); #else - merged.Save(stream, SaveOptions.None); - await Task.CompletedTask.ConfigureAwait(false); + merged.Save(stream, SaveOptions.None); + await Task.CompletedTask.ConfigureAwait(false); #endif + } + + ReplaceFile(tempPath, outputPath); + } + finally + { + TryDeleteFile(tempPath); + } + } + + private static string GetTempSiblingPath(string outputPath) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir + ? dir + : Directory.GetCurrentDirectory(); + return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + } + + private static void ReplaceFile(string tempPath, string outputPath) + { + // Delete the destination entry (a regular file, or a symlink/hardlink alias) before moving the + // freshly written temp file into place. Deleting a link removes only the link, never its target's + // content, so a source aliased by the output path is never truncated. An exact (case-insensitive) + // alias of an input has already been rejected, so this cannot delete an input in place. + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + + File.Move(tempPath, outputPath); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary + // exception (or the successful result) with a cleanup failure. + } } private static long ReadLong(XElement element, string attributeName) @@ -174,17 +228,16 @@ private static bool TryReadTimestamp(XElement element, string attributeName, out /// /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites - /// a source report (RFC 018 keeps inputs on disk, read-only). + /// a source report (RFC 018 keeps inputs on disk, read-only). Compares case-insensitively on every + /// platform: Windows and the default macOS volume are case-insensitive, and treating a case-differing + /// path as an alias only makes this guard more conservative on a case-sensitive volume. /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { - StringComparison comparison = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) - ? StringComparison.OrdinalIgnoreCase - : StringComparison.Ordinal; string outputFull = Path.GetFullPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(Path.GetFullPath(inputPath), outputFull, comparison)) + if (string.Equals(Path.GetFullPath(inputPath), outputFull, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index aaf3724f1c..c0650de45e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -201,13 +201,67 @@ internal static async Task MergeToFileAsync( XDocument merged = Merge(reports, runId, runName); - using FileStream stream = File.Create(outputPath); + // Write to a temporary sibling, then replace the destination ENTRY. If the output path is a + // symlink/hardlink alias of an input (which the textual alias check above cannot detect because + // Path.GetFullPath does not resolve links), replacing the entry removes only the link and leaves + // the read-only source intact, rather than truncating it in place via File.Create. + string tempPath = GetTempSiblingPath(outputPath); + try + { + using (FileStream stream = File.Create(tempPath)) + { #if NETCOREAPP - await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); + await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); #else - merged.Save(stream, SaveOptions.None); - await Task.CompletedTask.ConfigureAwait(false); + merged.Save(stream, SaveOptions.None); + await Task.CompletedTask.ConfigureAwait(false); #endif + } + + ReplaceFile(tempPath, outputPath); + } + finally + { + TryDeleteFile(tempPath); + } + } + + private static string GetTempSiblingPath(string outputPath) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir + ? dir + : Directory.GetCurrentDirectory(); + return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + } + + private static void ReplaceFile(string tempPath, string outputPath) + { + // Delete the destination entry (a regular file, or a symlink/hardlink alias) before moving the + // freshly written temp file into place. Deleting a link removes only the link, never its target's + // content, so a source aliased by the output path is never truncated. An exact (case-insensitive) + // alias of an input has already been rejected, so this cannot delete an input in place. + if (File.Exists(outputPath)) + { + File.Delete(outputPath); + } + + File.Move(tempPath, outputPath); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary + // exception (or the successful result) with a cleanup failure. + } } private static XElement? FindChild(XElement parent, string localName) @@ -362,7 +416,7 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO CopyDirectoryRecursive(sourceInRoot, destForInput, cancellationToken); } - RewriteAttachmentHrefs(reports[i], prefix); + RewriteAttachmentHrefs(reports[i], prefix, mergedInRoot); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { @@ -373,7 +427,7 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } } - private static void RewriteAttachmentHrefs(XDocument report, string prefix) + private static void RewriteAttachmentHrefs(XDocument report, string prefix, string mergedInRoot) { if (report.Root is not { } root) { @@ -388,16 +442,19 @@ private static void RewriteAttachmentHrefs(XDocument report, string prefix) // In/// is reached by prefixing the *directory*, not // the path. A ResultFile without an owning relativeResultsDirectory behaves like a collector // href (In/). - // Materialize each pass first: an escaping reference is removed, and removing while enumerating - // the lazy Descendants() sequence would be unsafe. + // Every rewritten reference is also verified against the freshly relocated tree (mergedInRoot) and + // dropped if the target was not materialized (e.g. a skipped source symlink or a partial copy), + // so the merged TRX never carries a dangling reference. + // Materialize each pass first: an escaping/missing reference is removed, and removing while + // enumerating the lazy Descendants() sequence would be unsafe. foreach (XElement collectorAttachment in root.Descendants().Where(e => e.Name.LocalName == "A").ToList()) { - PrefixRelativeAttribute(collectorAttachment, "href", prefix); + PrefixRelativeAttribute(collectorAttachment, "href", prefix, mergedInRoot); } foreach (XElement unitTestResult in root.Descendants().Where(e => e.Name.LocalName == "UnitTestResult").ToList()) { - PrefixResultFilesForUnitTestResult(unitTestResult, prefix); + PrefixResultFilesForUnitTestResult(unitTestResult, prefix, mergedInRoot); } // Any standalone ResultFile (no owning UnitTestResult, e.g. from a foreign producer) resolves at @@ -407,7 +464,7 @@ private static void RewriteAttachmentHrefs(XDocument report, string prefix) .Where(e => e.Name.LocalName == "ResultFile" && e.Ancestors().All(a => a.Name.LocalName != "UnitTestResult")) .ToList()) { - PrefixRelativeAttribute(standaloneResultFile, "path", prefix); + PrefixRelativeAttribute(standaloneResultFile, "path", prefix, mergedInRoot); } } @@ -415,10 +472,10 @@ private static void RewriteAttachmentHrefs(XDocument report, string prefix) /// Prefixes the per-test ResultFile references of a single UnitTestResult. Consumers /// resolve them under In/<relativeResultsDirectory>/<path>, so the relocated copy /// (under In/<prefix>/<relativeResultsDirectory>/<path>) is reached by - /// prefixing the directory once; a reference whose combined directory/path escapes the root (or is - /// rooted) is dropped instead of emitted. + /// prefixing the directory once; a reference whose combined directory/path escapes the root (is + /// rooted, or whose relocated file does not exist) is dropped instead of emitted. /// - private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, string prefix) + private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, string prefix, string mergedInRoot) { List resultFiles = [.. unitTestResult.Descendants().Where(e => e.Name.LocalName == "ResultFile")]; if (resultFiles.Count == 0) @@ -434,20 +491,22 @@ private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, // No owning directory: each path resolves at In/, like a collector href. foreach (XElement resultFile in resultFiles) { - PrefixRelativeAttribute(resultFile, "path", prefix); + PrefixRelativeAttribute(resultFile, "path", prefix, mergedInRoot); } return; } - // Validate the combined directory/path (that is what a consumer resolves) and drop any escaping - // or rooted reference, so a hostile relativeResultsDirectory such as '../../..' cannot make the - // merged TRX point outside its deployment root. + // Validate the combined directory/path (that is what a consumer resolves): drop any escaping, + // rooted, or non-materialized reference, so a hostile relativeResultsDirectory such as '../../..' + // cannot make the merged TRX point outside its deployment root and a skipped file never dangles. bool anyKept = false; foreach (XElement resultFile in resultFiles) { string path = resultFile.Attribute("path")?.Value ?? string.Empty; - if (Path.IsPathRooted(relativeDirectoryValue) || Path.IsPathRooted(path) || EscapesRoot(relativeDirectoryValue + "/" + path)) + if (Path.IsPathRooted(relativeDirectoryValue) || Path.IsPathRooted(path) + || EscapesRoot(relativeDirectoryValue + "/" + path) + || !ResolvedFileExists(mergedInRoot, prefix + "/" + relativeDirectoryValue + "/" + path)) { resultFile.Remove(); } @@ -465,7 +524,7 @@ private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, } } - private static void PrefixRelativeAttribute(XElement element, string attributeName, string prefix) + private static void PrefixRelativeAttribute(XElement element, string attributeName, string prefix, string mergedInRoot) { XAttribute? attribute = element.Attribute(attributeName); if (attribute is null || RoslynString.IsNullOrEmpty(attribute.Value) || Path.IsPathRooted(attribute.Value)) @@ -482,9 +541,22 @@ private static void PrefixRelativeAttribute(XElement element, string attributeNa return; } - attribute.Value = prefix + "/" + attribute.Value; + string prefixedValue = prefix + "/" + attribute.Value; + + // Drop the reference if the relocation did not materialize the target (a skipped source symlink + // or a partial copy), so the merged TRX never carries a dangling reference. + if (!ResolvedFileExists(mergedInRoot, prefixedValue)) + { + element.Remove(); + return; + } + + attribute.Value = prefixedValue; } + private static bool ResolvedFileExists(string mergedInRoot, string relativePath) + => File.Exists(Path.Combine(mergedInRoot, relativePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar))); + /// /// Returns if the relative resolves above its /// own root (i.e. a .. segment pops past the start), meaning it would escape the confined @@ -665,8 +737,12 @@ private static bool HasReparsePointComponent(string candidateFullPath, string ba return false; } - private static StringComparison PathComparison - => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + // Path comparisons here gate destructive/aliasing decisions (equal-roots skips, overlap detection, + // output-alias rejection). Windows and the default macOS volume are case-insensitive, and even on a + // case-sensitive Linux volume treating two case-differing paths as the same only makes these guards + // MORE conservative (skip relocation / reject an output) — never less safe. So compare + // case-insensitively on every platform rather than guessing case sensitivity from the OS name. + private static StringComparison PathComparison => StringComparison.OrdinalIgnoreCase; private static void DeleteDirectoryTreeOrLink(string path) { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 9a7e207b21..789397c71d 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -481,6 +481,48 @@ public async Task MergeToFileAsync_WhenResultDirectoryEscapesRoot_DropsResultFil } } + [TestMethod] + public async Task MergeToFileAsync_WhenReferencedAttachmentIsNotMaterialized_DropsTheReference() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The report references an attachment that has no physical backing (e.g. it was a skipped + // source symlink, or a partial copy). The relocation must not emit a dangling href. + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(Path.Combine(inputDir, "dep", "In", "machine")); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/missing.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + [TestMethod] public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfinedDeploymentRoot() { diff --git a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs index 2de76778fd..d7390c0edc 100644 --- a/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Platform.DotnetTestProtocolContract.UnitTests/DotnetTestProtocolSerializerTests.cs @@ -145,6 +145,21 @@ static ushort GetConstantValue(string fieldName) => (ushort)typeof(FailedTestResultMessageFieldsId).GetField(fieldName)!.GetRawConstantValue()!; } + [TestMethod] + public void FileArtifactMessageFieldIds_Kind_IsStable() + { + // Kind is an externally shared wire id (the SDK decodes it with its own vendored copy of the same + // number), so renumbering it would silently break cross-process decoding without failing the + // round-trip tests (which use the same constant on both writer and reader). Pin the literal here. + // The declared value is read via reflection (a runtime read, not a compile-time constant) so that + // renumbering the constant is actually caught rather than folded away by the compiler. + Assert.AreEqual((ushort)6, GetConstantValue(nameof(FileArtifactMessageFieldsId.SessionUid))); + Assert.AreEqual((ushort)7, GetConstantValue(nameof(FileArtifactMessageFieldsId.Kind))); + + static ushort GetConstantValue(string fieldName) + => (ushort)typeof(FileArtifactMessageFieldsId).GetField(fieldName)!.GetRawConstantValue()!; + } + [TestMethod] public void DiscoveredTestMessages_RoundTripsWithTraitsAndParameters() { From bac64fb501f6d6a342279780ff4d374685ee1162 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 19:12:08 +0200 Subject: [PATCH 16/26] Copy only referenced attachments in TRX merge (RFC 018 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the whole-deployment-tree relocation with a copy-only-referenced-files approach, which eliminates the recurring tree-copy edge cases: - Only the attachment files a report actually references are copied into the per-input folder (never a directory tree), so an output nested inside a source neither recurses into its own destination nor accumulates deeper copies across retries — the nested-layout merge is now bounded to a single relocated copy. - Drop rooted attachment references (an absolute href/path pointing outside the confined deployment tree) just like traversal references, instead of preserving an absolute path. - Per-file confinement: each source/destination is confined under its root and any file behind a symlinked component, or a symlink file itself, is skipped (its reference dropped) so a link can never redirect the read/write. - Removes CopyViaStaging / CopyDirectoryRecursive / DeleteDirectoryTreeOrLink / ContainsAnyDirectory and the protected-directories pre-scan they required. Tests: nested-layout is now asserted bounded (2 copies), rooted-href drop added. 57 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../TrxReportEngine.Merge.cs | 355 +++++------------- .../TrxReportEngineMergeTests.cs | 58 ++- 2 files changed, 156 insertions(+), 257 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index c0650de45e..95b96989f9 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -268,12 +268,15 @@ private static void TryDeleteFile(string path) => parent.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, localName, StringComparison.Ordinal)); /// - /// Relocates each input report's attachment deployment tree (<deploymentRoot>/In) into a - /// per-input isolated folder under the merged report's deployment root and rewrites that input's - /// attachment hrefs to match, so references carried into the merged TRX keep resolving without one - /// module's files shadowing another's. All destination and source paths are confined - /// (runName and each input's runDeploymentRoot are attacker-influenced), and any - /// copy failure is swallowed so it never fails the merge (never-fail-the-run invariant). + /// Copies each input report's referenced attachment files into a per-input isolated folder + /// under the merged report's deployment root and rewrites that input's references to match, so + /// attachments carried into the merged TRX keep resolving without one module's files shadowing + /// another's. Only the files actually referenced by the report are copied (never a whole directory + /// tree), which keeps the operation non-destructive (inputs stay read-only, RFC 018), bounded (no + /// recursion or unbounded accumulation when the output is nested inside a source), and confined + /// (runName and each input's runDeploymentRoot are attacker-influenced). Any copy + /// failure is swallowed so it never fails the merge (never-fail-the-run invariant); a reference that + /// cannot be relocated is dropped rather than left dangling or pointing outside the deployment root. /// private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, string runName, CancellationToken cancellationToken) { @@ -291,41 +294,6 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO string mergedInRoot = Path.Combine(mergedRootFull, "In"); - // Absolute directories that must never be wholesale-deleted by relocation: each input report's - // parent directory AND each input's resolved source 'In' root. Deleting a destination that - // contains one of these would remove the originals (which RFC 018 requires to stay on disk) or a - // sibling/later input's report and attachments. - var protectedDirectories = new List(inputPaths.Count * 2); - for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) - { - try - { - if (Path.GetDirectoryName(Path.GetFullPath(inputPaths[i])) is not { Length: > 0 } inputDir) - { - continue; - } - - protectedDirectories.Add(inputDir); - - string? deploymentRoot = reports[i].Root is { } r - ? FindChild(r, "TestSettings")?.Elements().FirstOrDefault(e => string.Equals(e.Name.LocalName, "Deployment", StringComparison.Ordinal))?.Attribute("runDeploymentRoot")?.Value - : null; - - if (!RoslynString.IsNullOrEmpty(deploymentRoot)) - { - string source = Path.GetFullPath(Path.Combine(inputDir, deploymentRoot, "In")); - if (IsUnderDirectory(source, inputDir)) - { - protectedDirectories.Add(source); - } - } - } - catch (Exception ex) when (ex is ArgumentException or NotSupportedException) - { - // Ignore malformed input paths here; the per-input loop handles them. - } - } - // On a reused output directory the merged 'In' root could pre-exist as a junction/symlink that // would redirect every copy below it outside the confined merged root. Remove such a link so a // fresh, real directory is created instead. @@ -343,9 +311,9 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO for (int i = 0; i < inputPaths.Count && i < reports.Count; i++) { - // Cancellation must interrupt an otherwise unbounded copy of a large deployment tree; check - // before each input (and inside CopyDirectoryRecursive) and let OperationCanceledException - // propagate rather than being swallowed as a best-effort failure below. + // Cancellation must interrupt an otherwise long sequence of file copies; check before each + // input (and inside TryCopyReferencedFile) and let OperationCanceledException propagate rather + // than being swallowed as a best-effort failure below. cancellationToken.ThrowIfCancellationRequested(); try @@ -374,49 +342,16 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } // Equal roots: the source files already live at the merged deployment root under their - // original relative paths, so the original hrefs resolve as-is. Skip relocation and - // leave the references un-prefixed. + // original relative paths, so the original references resolve as-is. Skip relocation and + // leave them un-prefixed. if (string.Equals(sourceInRoot, mergedInRoot, PathComparison)) { continue; } - string prefix = i.ToString(CultureInfo.InvariantCulture); - string destForInput = Path.Combine(mergedInRoot, prefix); - - // Strict overlap (one 'In' root is nested inside the other, e.g. the output directory was - // placed inside an input's deployment tree): copying source directly into a subfolder of - // the merged root would recurse into its own destination. Stage the copy through a - // temporary directory outside both trees, then overlay it into place, so the per-input - // prefix rewrite below is always correct without recursing. - bool strictOverlap = IsUnderDirectory(mergedInRoot, sourceInRoot) || IsUnderDirectory(sourceInRoot, mergedInRoot); - - if (strictOverlap) - { - // Non-destructive: never delete or omit any part of the source tree (RFC 018 treats - // inputs as read-only and requires them to stay on disk). We deliberately do NOT try - // to infer which files under an overlapping path are the merger's own prior output — - // that inference could drop legitimate source attachments — so we copy everything and - // overlay (per-file overwrite). In the pathological nested-output configuration this - // may leave benign stale bytes from an earlier retry, which is preferred over ever - // removing an original. - CopyViaStaging(sourceInRoot, destForInput, cancellationToken); - } - else - { - // Disjoint trees: the destination lives under the merged 'In' root, outside every - // source tree, so recreating it as a fresh, non-link tree is safe and clears stale - // bytes / redirecting junctions from a reused output directory. Guard only against a - // destination that happens to hold an input report or its resolved source root. - if (!ContainsAnyDirectory(destForInput, protectedDirectories)) - { - DeleteDirectoryTreeOrLink(destForInput); - } - - CopyDirectoryRecursive(sourceInRoot, destForInput, cancellationToken); - } - - RewriteAttachmentHrefs(reports[i], prefix, mergedInRoot); + // Copy only the files the report references (never the whole tree), so an output nested + // inside a source neither recurses into its own destination nor accumulates across retries. + RelocateReferencedAttachments(reports[i], i.ToString(CultureInfo.InvariantCulture), sourceInRoot, mergedInRoot, cancellationToken); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { @@ -427,7 +362,7 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } } - private static void RewriteAttachmentHrefs(XDocument report, string prefix, string mergedInRoot) + private static void RelocateReferencedAttachments(XDocument report, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) { if (report.Root is not { } root) { @@ -435,47 +370,42 @@ private static void RewriteAttachmentHrefs(XDocument report, string prefix, stri } // Attachment references come in two shapes with different resolution rules: - // * Collector attachments '' are relative to the deployment 'In' root, so the - // copy under In//... is reached by prefixing the href. - // * Per-test '' are resolved by consumers under - // In//, so the copy under - // In/// is reached by prefixing the *directory*, not - // the path. A ResultFile without an owning relativeResultsDirectory behaves like a collector - // href (In/). - // Every rewritten reference is also verified against the freshly relocated tree (mergedInRoot) and - // dropped if the target was not materialized (e.g. a skipped source symlink or a partial copy), - // so the merged TRX never carries a dangling reference. - // Materialize each pass first: an escaping/missing reference is removed, and removing while - // enumerating the lazy Descendants() sequence would be unsafe. + // * Collector attachments '' (and a standalone '' from a + // foreign producer) are relative to the deployment 'In' root, so the file is copied to + // In// and the value is prefixed. + // * Per-test '' under a UnitTestResult are resolved by consumers under + // In//, so the file is copied to + // In/// and the *directory* is prefixed once. + // Only referenced files are copied (never a whole tree), and a reference that is rooted, escapes + // the deployment root, or whose source file was not materialized is dropped. + // Materialize each pass first: a dropped reference is removed, and removing while enumerating the + // lazy Descendants() sequence would be unsafe. foreach (XElement collectorAttachment in root.Descendants().Where(e => e.Name.LocalName == "A").ToList()) { - PrefixRelativeAttribute(collectorAttachment, "href", prefix, mergedInRoot); + RelocateReference(collectorAttachment, "href", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, cancellationToken); } foreach (XElement unitTestResult in root.Descendants().Where(e => e.Name.LocalName == "UnitTestResult").ToList()) { - PrefixResultFilesForUnitTestResult(unitTestResult, prefix, mergedInRoot); + RelocateResultFilesForUnitTestResult(unitTestResult, prefix, sourceInRoot, mergedInRoot, cancellationToken); } - // Any standalone ResultFile (no owning UnitTestResult, e.g. from a foreign producer) resolves at - // In/, like a collector href. Materialized after the pass above so ResultFiles already - // dropped there do not reappear here. foreach (XElement standaloneResultFile in root.Descendants() .Where(e => e.Name.LocalName == "ResultFile" && e.Ancestors().All(a => a.Name.LocalName != "UnitTestResult")) .ToList()) { - PrefixRelativeAttribute(standaloneResultFile, "path", prefix, mergedInRoot); + RelocateReference(standaloneResultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, cancellationToken); } } /// - /// Prefixes the per-test ResultFile references of a single UnitTestResult. Consumers - /// resolve them under In/<relativeResultsDirectory>/<path>, so the relocated copy - /// (under In/<prefix>/<relativeResultsDirectory>/<path>) is reached by - /// prefixing the directory once; a reference whose combined directory/path escapes the root (is - /// rooted, or whose relocated file does not exist) is dropped instead of emitted. + /// Relocates the per-test ResultFile references of a single UnitTestResult. Consumers + /// resolve them under In/<relativeResultsDirectory>/<path>, so each referenced file + /// is copied to In/<prefix>/<relativeResultsDirectory>/<path> and the + /// directory is prefixed once; a reference that is rooted, escapes the root, or whose source file was + /// not materialized is dropped. /// - private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, string prefix, string mergedInRoot) + private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) { List resultFiles = [.. unitTestResult.Descendants().Where(e => e.Name.LocalName == "ResultFile")]; if (resultFiles.Count == 0) @@ -491,22 +421,31 @@ private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, // No owning directory: each path resolves at In/, like a collector href. foreach (XElement resultFile in resultFiles) { - PrefixRelativeAttribute(resultFile, "path", prefix, mergedInRoot); + RelocateReference(resultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, cancellationToken); + } + + return; + } + + // A rooted owning directory cannot be safely relocated; drop all its references. + if (Path.IsPathRooted(relativeDirectoryValue)) + { + foreach (XElement resultFile in resultFiles) + { + resultFile.Remove(); } return; } - // Validate the combined directory/path (that is what a consumer resolves): drop any escaping, - // rooted, or non-materialized reference, so a hostile relativeResultsDirectory such as '../../..' - // cannot make the merged TRX point outside its deployment root and a skipped file never dangles. + // Copy each referenced file to In///, dropping any rooted, + // escaping, or non-materialized reference, then prefix the directory once if anything survived. bool anyKept = false; foreach (XElement resultFile in resultFiles) { string path = resultFile.Attribute("path")?.Value ?? string.Empty; - if (Path.IsPathRooted(relativeDirectoryValue) || Path.IsPathRooted(path) - || EscapesRoot(relativeDirectoryValue + "/" + path) - || !ResolvedFileExists(mergedInRoot, prefix + "/" + relativeDirectoryValue + "/" + path)) + string combined = relativeDirectoryValue + "/" + path; + if (Path.IsPathRooted(path) || EscapesRoot(combined) || !TryCopyReferencedFile(sourceInRoot, mergedInRoot, prefix, combined, cancellationToken)) { resultFile.Remove(); } @@ -516,46 +455,81 @@ private static void PrefixResultFilesForUnitTestResult(XElement unitTestResult, } } - // Prefix the directory once (only if at least one reference survived) so every kept path resolves - // to the relocated copy. if (anyKept) { relativeDirectory.Value = prefix + "/" + relativeDirectoryValue; } } - private static void PrefixRelativeAttribute(XElement element, string attributeName, string prefix, string mergedInRoot) + /// + /// Copies the file referenced by into the per-input folder and + /// prefixes the reference. A rooted value (an absolute path outside the confined deployment tree), a + /// value that escapes the deployment root, or a source file that was not materialized (missing, or a + /// skipped symlink) causes the reference to be dropped instead of emitted. + /// + private static void RelocateReference(XElement element, string attributeName, string? relativeDirectory, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) { XAttribute? attribute = element.Attribute(attributeName); - if (attribute is null || RoslynString.IsNullOrEmpty(attribute.Value) || Path.IsPathRooted(attribute.Value)) + if (attribute is null || RoslynString.IsNullOrEmpty(attribute.Value)) { return; } - // A relative value such as '../../../secret' passes the rooted check yet, once resolved from the - // merged deployment root, escapes the confined output tree. Drop the reference entirely rather - // than emit a traversal href a downstream TRX consumer could resolve outside the output. - if (EscapesRoot(attribute.Value)) + if (Path.IsPathRooted(attribute.Value)) { element.Remove(); return; } - string prefixedValue = prefix + "/" + attribute.Value; - - // Drop the reference if the relocation did not materialize the target (a skipped source symlink - // or a partial copy), so the merged TRX never carries a dangling reference. - if (!ResolvedFileExists(mergedInRoot, prefixedValue)) + string relative = relativeDirectory is null ? attribute.Value : relativeDirectory + "/" + attribute.Value; + if (EscapesRoot(relative) || !TryCopyReferencedFile(sourceInRoot, mergedInRoot, prefix, relative, cancellationToken)) { element.Remove(); return; } - attribute.Value = prefixedValue; + attribute.Value = prefix + "/" + attribute.Value; } - private static bool ResolvedFileExists(string mergedInRoot, string relativePath) - => File.Exists(Path.Combine(mergedInRoot, relativePath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar))); + /// + /// Copies a single referenced attachment file from to the per-input + /// folder under , both confined. Returns (so the + /// caller drops the reference) when the source file is missing, is a symlink, sits behind a symlinked + /// component, or when either resolved path escapes its root. is already + /// known to be non-rooted and non-escaping. + /// + private static bool TryCopyReferencedFile(string sourceInRoot, string mergedInRoot, string prefix, string relative, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string normalized = relative.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); + string sourceFile = Path.GetFullPath(Path.Combine(sourceInRoot, normalized)); + string destinationFile = Path.GetFullPath(Path.Combine(mergedInRoot, prefix, normalized)); + + // Confine both ends and refuse anything behind a symlinked component or a symlinked file, so a + // link cannot redirect the read or write outside the confined trees. + if (!IsUnderDirectory(sourceFile, sourceInRoot) + || !IsUnderDirectory(destinationFile, mergedInRoot) + || !File.Exists(sourceFile) + || IsReparsePoint(sourceFile) + || HasReparsePointComponent(Path.GetDirectoryName(sourceFile)!, sourceInRoot) + || HasReparsePointComponent(Path.GetDirectoryName(destinationFile)!, mergedInRoot)) + { + return false; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); + + // A pre-existing destination symlink (from a reused output directory) would make the overwrite + // follow the link and write outside the merged root. Remove it so a real file is written. + if (File.Exists(destinationFile) && IsReparsePoint(destinationFile)) + { + File.Delete(destinationFile); + } + + File.Copy(sourceFile, destinationFile, overwrite: true); + return true; + } /// /// Returns if the relative resolves above its @@ -601,108 +575,6 @@ private static bool IsUnderDirectory(string candidateFullPath, string rootDirect || candidateFullPath.StartsWith(rootWithSeparator, comparison); } - /// - /// Copies to via a - /// temporary staging directory outside both trees. Used when the source and the merged destination - /// strictly overlap, so the copy never recurses into its own destination while still landing the - /// files at the prefixed destination (keeping the rewritten hrefs valid). The copy is non-destructive: - /// nothing under the source is deleted or omitted (RFC 018 keeps inputs on disk, read-only); the - /// destination is overlaid per file. - /// - private static void CopyViaStaging(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) - { - string staging = Path.Combine(Path.GetTempPath(), "mtp-trx-merge-" + Guid.NewGuid().ToString("N")); - try - { - // Snapshot the source to a temp location OUTSIDE both trees first, so the final copy into a - // destination that may be nested inside the source never recurses into itself. - CopyDirectoryRecursive(sourceDirectory, staging, cancellationToken); - CopyDirectoryRecursive(staging, destinationDirectory, cancellationToken); - } - finally - { - // Best-effort cleanup: a failure here must not replace an in-flight OperationCanceledException - // (or the real merge exception) with an I/O/authorization error. - try - { - if (Directory.Exists(staging)) - { - Directory.Delete(staging, recursive: true); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Swallowed on purpose: the staging directory lives under the temp path and leaking it is - // preferable to masking the primary exception that is already unwinding. - } - } - } - - private static bool ContainsAnyDirectory(string candidateDirectory, IReadOnlyList directories) - { - string candidateFull = Path.GetFullPath(candidateDirectory); - foreach (string directory in directories) - { - if (IsUnderDirectory(Path.GetFullPath(directory), candidateFull)) - { - return true; - } - } - - return false; - } - - private static void CopyDirectoryRecursive(string sourceDirectory, string destinationDirectory, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - // Do not descend into reparse points (symlinks/junctions): a link inside the (confined) source - // tree could otherwise redirect the copy to an arbitrary location. - if (IsReparsePoint(sourceDirectory)) - { - return; - } - - // The destination is recreated fresh by the caller, but guard defensively for nested levels: - // never write through a destination directory that is itself a link. - if (Directory.Exists(destinationDirectory) && IsReparsePoint(destinationDirectory)) - { - Directory.Delete(destinationDirectory); - } - - Directory.CreateDirectory(destinationDirectory); - - foreach (string file in Directory.GetFiles(sourceDirectory)) - { - cancellationToken.ThrowIfCancellationRequested(); - - // A source file reparse point (symlink) would make File.Copy follow the link and pull in - // content from outside the confined tree; skip it. - if (IsReparsePoint(file)) - { - continue; - } - - // Guard the destination too: a pre-existing destination symlink would make the overwrite - // follow the link and write outside the merged root. Remove it so a real file is written. - string destination = Path.Combine(destinationDirectory, Path.GetFileName(file)); - if (File.Exists(destination) && IsReparsePoint(destination)) - { - File.Delete(destination); - } - - // Overwrite so a reused output directory can't leave stale bytes behind while the merged - // XML is rewritten to reference the (per-input isolated) destination, mirroring the - // File.Create overwrite used for the merged TRX itself. - File.Copy(file, destination, overwrite: true); - } - - foreach (string directory in Directory.GetDirectories(sourceDirectory)) - { - CopyDirectoryRecursive(directory, Path.Combine(destinationDirectory, Path.GetFileName(directory)), cancellationToken); - } - } - private static bool IsReparsePoint(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; @@ -744,25 +616,6 @@ private static bool HasReparsePointComponent(string candidateFullPath, string ba // case-insensitively on every platform rather than guessing case sensitivity from the OS name. private static StringComparison PathComparison => StringComparison.OrdinalIgnoreCase; - private static void DeleteDirectoryTreeOrLink(string path) - { - if (!Directory.Exists(path)) - { - return; - } - - // Deleting a directory reparse point non-recursively removes only the link, never its target's - // contents; a real directory is deleted recursively (the runtime does not follow nested links). - if (IsReparsePoint(path)) - { - Directory.Delete(path); - } - else - { - Directory.Delete(path, recursive: true); - } - } - private static void CloneChildrenInto(XElement? source, XElement destination) { if (source is null) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 789397c71d..2ffc6272d4 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -338,6 +338,49 @@ await Assert.ThrowsExactlyAsync( } } + [TestMethod] + public async Task MergeToFileAsync_WhenAttachmentHrefIsRooted_DropsTheReference() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // A rooted (absolute) href points outside the confined deployment tree; the path-confined + // merge must drop it rather than preserve an absolute path to some file on disk. + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(Path.Combine(inputDir, "dep", "In", "machine")); + + string rootedHref = Path.Combine(tempDirectory, "outside", "secret.txt"); + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", rootedHref)))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + [TestMethod] public async Task MergeToFileAsync_WhenAttachmentHrefEscapesRoot_DropsTheReference() { @@ -615,17 +658,16 @@ public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndK } [TestMethod] - public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_NeverDeletesOriginalSource() + public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_IsBoundedAndNonDestructive() { string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDirectory); try { // Layout where the merged deployment root is nested strictly INSIDE the input's source 'In' - // tree. Relocation is deliberately non-destructive: it never infers ownership from path - // overlap and never deletes or omits any source bytes (RFC 018 keeps inputs read-only). It - // may leave benign stale copies from earlier retries, which is preferred over ever removing - // an original — so we assert preservation and resolvability, not a bounded copy count. + // tree. Because only referenced attachment files are copied (never the whole tree), repeated + // merges into the same output neither recurse into their own destination nor accumulate + // deeper copies: the original stays untouched and there is exactly one relocated copy. string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "dep", "In", "out", "merged.trx"); @@ -642,7 +684,11 @@ public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_NeverDeletesOrig Assert.IsTrue(File.Exists(originalAttachment)); Assert.AreEqual("AAA", File.ReadAllText(originalAttachment)); - // The merged report's rewritten href must resolve to a real relocated copy under the merged + // Original attachment + exactly one relocated copy — bounded, not one-per-run. + List copies = [.. Directory.GetFiles(tempDirectory, "log.txt", SearchOption.AllDirectories)]; + Assert.HasCount(2, copies); + + // The merged report's rewritten href must resolve to the relocated copy under the merged // deployment root ('/run/In'). List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; Assert.HasCount(1, hrefs); From 2b7a860dac6802e909a723b9b66285e06139afd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 19:37:00 +0200 Subject: [PATCH 17/26] Drop unrelocated references and detect symlinked-parent output aliases (RFC 018 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reference safety on skip/failure: when an input's attachments cannot be relocated (missing/invalid source, or a copy that throws mid-way) the merge now drops ALL of that input's / references instead of carrying them through unchanged. They are relative to the input's own deployment root, so against the merged deployment root they would dangle or bind to stale files. - Output aliasing via symlinked parent: EnsureOutputDoesNotAliasInput now canonicalizes paths (resolving symlinks/junctions in every existing component via ResolveLinkTarget) before the case-insensitive comparison, so an output whose parent directory is a link aliasing an input directory is detected and rejected — closing the gap where Path.GetFullPath only normalized text and ReplaceFile could then delete the input. Applied to TRX/JUnit/CTRF; falls back to the lexical path on .NET Framework. Note: the rooted-reference finding was already fixed in bac64fb50 (RelocateReference drops rooted values). Tests: drop-all-on-missing-source, and per-merger symlinked-parent alias rejection (guarded to netcoreapp, skipped when dir symlinks need privileges). 61 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 50 +++++++++- .../JUnitReportMerger.cs | 50 +++++++++- .../TrxReportEngine.Merge.cs | 76 ++++++++++++++- .../CtrfReportMergerTests.cs | 46 +++++++++ .../JUnitReportMergerTests.cs | 46 +++++++++ .../TrxReportEngineMergeTests.cs | 94 +++++++++++++++++++ 6 files changed, 350 insertions(+), 12 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 93fb2e839a..ed16763674 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -363,22 +363,62 @@ private static string CreateDeterministicReportId(IReadOnlyList inputRep /// /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites - /// a source report (RFC 018 keeps inputs on disk, read-only). Compares case-insensitively on every - /// platform: Windows and the default macOS volume are case-insensitive, and treating a case-differing - /// path as an alias only makes this guard more conservative on a case-sensitive volume. + /// a source report (RFC 018 keeps inputs on disk, read-only). Paths are canonicalized (symlinks + /// resolved where the runtime supports it) and compared case-insensitively, so a differently-cased + /// path or a symlinked parent directory that aliases an input directory is still detected. /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { - string outputFull = Path.GetFullPath(outputPath); + string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(Path.GetFullPath(inputPath), outputFull, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } + /// + /// Canonicalizes to a full path with symlinks/junctions resolved in every + /// existing component (so a symlinked parent directory that aliases another location is detected). On + /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full + /// path. + /// + private static string GetCanonicalPath(string path) + { + string full = Path.GetFullPath(path); +#if NETCOREAPP + try + { + string? root = Path.GetPathRoot(full); + if (RoslynString.IsNullOrEmpty(root)) + { + return full; + } + + string resolved = root; + foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) + { + string next = Path.Combine(resolved, part); + resolved = Directory.Exists(next) + ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : File.Exists(next) + ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : next; + } + + return resolved; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return full; + } +#else + return full; +#endif + } + private static long Min(long? current, long candidate) => current is null || candidate < current ? candidate : current.Value; diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index 8c390fe90d..9f673854e0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -228,19 +228,59 @@ private static bool TryReadTimestamp(XElement element, string attributeName, out /// /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites - /// a source report (RFC 018 keeps inputs on disk, read-only). Compares case-insensitively on every - /// platform: Windows and the default macOS volume are case-insensitive, and treating a case-differing - /// path as an alias only makes this guard more conservative on a case-sensitive volume. + /// a source report (RFC 018 keeps inputs on disk, read-only). Paths are canonicalized (symlinks + /// resolved where the runtime supports it) and compared case-insensitively, so a differently-cased + /// path or a symlinked parent directory that aliases an input directory is still detected. /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { - string outputFull = Path.GetFullPath(outputPath); + string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(Path.GetFullPath(inputPath), outputFull, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } + + /// + /// Canonicalizes to a full path with symlinks/junctions resolved in every + /// existing component (so a symlinked parent directory that aliases another location is detected). On + /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full + /// path. + /// + private static string GetCanonicalPath(string path) + { + string full = Path.GetFullPath(path); +#if NETCOREAPP + try + { + string? root = Path.GetPathRoot(full); + if (RoslynString.IsNullOrEmpty(root)) + { + return full; + } + + string resolved = root; + foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) + { + string next = Path.Combine(resolved, part); + resolved = Directory.Exists(next) + ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : File.Exists(next) + ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : next; + } + + return resolved; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return full; + } +#else + return full; +#endif + } } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 95b96989f9..5a30766dc4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -325,6 +325,10 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO if (RoslynString.IsNullOrEmpty(inputDirectory) || RoslynString.IsNullOrEmpty(inputDeploymentRoot)) { + // No resolvable source for this input's attachments. Its references are relative to the + // input's own deployment root, so carrying them unchanged into the merged report (which + // has a different deployment root) would dangle — drop them. + DropAllAttachmentReferences(reports[i]); continue; } @@ -338,6 +342,9 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO || !Directory.Exists(sourceInRoot) || HasReparsePointComponent(sourceInRoot, inputDirectory)) { + // The source cannot be safely relocated; drop this input's references so none dangle + // against the merged deployment root. + DropAllAttachmentReferences(reports[i]); continue; } @@ -358,10 +365,32 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO // Best-effort: a failed attachment copy (including a malformed path from a hostile // runDeploymentRoot, which Path.GetFullPath surfaces as ArgumentException/ // NotSupportedException) must not fail the merge. Cancellation is not caught here. + // Relocation may have stopped part-way, leaving some references un-rewritten; drop all of + // this input's references so none dangle against the merged deployment root. + DropAllAttachmentReferences(reports[i]); } } } + /// + /// Removes every attachment reference (<A> and <ResultFile>) from a report. + /// Used when an input's attachments cannot be relocated (missing/invalid source, or a failed copy), so + /// the merged report never carries a reference that would resolve against the merged deployment root + /// to a file that was never placed there. + /// + private static void DropAllAttachmentReferences(XDocument report) + { + if (report.Root is not { } root) + { + return; + } + + foreach (XElement element in root.Descendants().Where(e => e.Name.LocalName is "A" or "ResultFile").ToList()) + { + element.Remove(); + } + } + private static void RelocateReferencedAttachments(XDocument report, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) { if (report.Root is not { } root) @@ -724,19 +753,62 @@ private static Guid CreateDeterministicSettingsId(Guid runId) /// /// Rejects an output path that resolves to one of the input report paths. RFC 018 treats per-module /// inputs as read-only and requires them to remain on disk, so a merge must never overwrite a source. + /// Paths are canonicalized (symlinks resolved where the runtime supports it) and compared + /// case-insensitively, so a differently-cased path or a symlinked parent directory that aliases an + /// input directory is still detected. /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { - string outputFull = Path.GetFullPath(outputPath); + string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(Path.GetFullPath(inputPath), outputFull, PathComparison)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } + /// + /// Canonicalizes to a full path with symlinks/junctions resolved in every + /// existing component (so a symlinked parent directory that aliases another location is detected). On + /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full + /// path. + /// + private static string GetCanonicalPath(string path) + { + string full = Path.GetFullPath(path); +#if NETCOREAPP + try + { + string? root = Path.GetPathRoot(full); + if (RoslynString.IsNullOrEmpty(root)) + { + return full; + } + + string resolved = root; + foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) + { + string next = Path.Combine(resolved, part); + resolved = Directory.Exists(next) + ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : File.Exists(next) + ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : next; + } + + return resolved; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return full; + } +#else + return full; +#endif + } + /// /// Produces a single, confined deployment-root leaf from for use both in /// the emitted TestSettings/Deployment/@runDeploymentRoot and in attachment relocation, so the diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index 29df6d2c9b..ecaa9650f1 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -207,6 +207,52 @@ public async Task MergeToFileAsync_WritesMergedFileToDisk() } } +#if NETCOREAPP + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesInputViaSymlinkedParent_ThrowsAndPreservesInput() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string realDir = Path.Combine(tempDirectory, "real"); + Directory.CreateDirectory(realDir); + string input = Path.Combine(realDir, "a.json"); + File.WriteAllText(input, BuildReport()); + + string linkDir = Path.Combine(tempDirectory, "link"); + if (!TryCreateDirectorySymlink(linkDir, realDir)) + { + return; + } + + // Output goes through the symlinked parent, so it is the SAME physical file as the input. + string aliasedOutput = Path.Combine(linkDir, "a.json"); + await Assert.ThrowsExactlyAsync( + () => CtrfReportMerger.MergeToFileAsync([input], aliasedOutput, CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return Directory.Exists(linkPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +#endif + private static JsonObject Test(string name, string status) => new() { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs index 2abee9f91b..df4af6db53 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs @@ -143,6 +143,52 @@ await Assert.ThrowsExactlyAsync( } } +#if NETCOREAPP + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesInputViaSymlinkedParent_ThrowsAndPreservesInput() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string realDir = Path.Combine(tempDirectory, "real"); + Directory.CreateDirectory(realDir); + string input = Path.Combine(realDir, "a.xml"); + BuildReport(tests: 2).Save(input); + + string linkDir = Path.Combine(tempDirectory, "link"); + if (!TryCreateDirectorySymlink(linkDir, realDir)) + { + return; + } + + // Output goes through the symlinked parent, so it is the SAME physical file as the input. + string aliasedOutput = Path.Combine(linkDir, "a.xml"); + await Assert.ThrowsExactlyAsync( + () => JUnitReportMerger.MergeToFileAsync([input], aliasedOutput, "run", CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return Directory.Exists(linkPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +#endif + private static XElement Suite( string name, long tests = 1, diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 2ffc6272d4..f02874d3e1 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -566,6 +566,100 @@ public async Task MergeToFileAsync_WhenReferencedAttachmentIsNotMaterialized_Dro } } + [TestMethod] + public async Task MergeToFileAsync_WhenInputHasNoMaterializedSource_DropsAllReferences() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The report references an attachment but its deployment 'In' root does not exist at all, so + // the source cannot be relocated. Its references are relative to the input's own deployment + // root and would dangle against the (different) merged deployment root, so they must all be + // dropped rather than carried through unchanged. + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(inputDir); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/log.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.IsEmpty(hrefs); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + +#if NETCOREAPP + [TestMethod] + public async Task MergeToFileAsync_WhenOutputAliasesInputViaSymlinkedParent_ThrowsAndPreservesInput() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string realDir = Path.Combine(tempDirectory, "real"); + Directory.CreateDirectory(realDir); + string input = Path.Combine(realDir, "a.trx"); + BuildReport().Save(input); + + string linkDir = Path.Combine(tempDirectory, "link"); + if (!TryCreateDirectorySymlink(linkDir, realDir)) + { + // Directory symlinks require privileges on some platforms (e.g. non-elevated Windows); + // skip when unavailable rather than fail. + return; + } + + // Output goes through the symlinked parent, so it is the SAME physical file as the input even + // though the textual paths differ. Canonicalization must detect this and reject it, leaving + // the input untouched. + string aliasedOutput = Path.Combine(linkDir, "a.trx"); + await Assert.ThrowsExactlyAsync( + () => TrxReportEngine.MergeToFileAsync([input], aliasedOutput, Guid.NewGuid(), "run", CancellationToken.None)); + + Assert.IsTrue(File.Exists(input)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + + private static bool TryCreateDirectorySymlink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return Directory.Exists(linkPath); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +#endif + [TestMethod] public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfinedDeploymentRoot() { From 31f43de9bca9be0e32c19ef4db9aaab452c9a90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 19:52:49 +0200 Subject: [PATCH 18/26] Fix merge semantics: outcome, definition dedup, environment, reference validation (RFC 018 review) - Merged run outcome: treat every unsuccessful TRX summary outcome (Error, Aborted, Timeout, ...) and the error/aborted counters as non-success, not just the literal 'Failed', so an unsuccessful run is never reported as 'Completed'. - TestDefinition merge: keep identical definitions deduplicated, but when two inputs share a definition id yet differ (e.g. the same test across TFMs with different storage) remap the later one to a fresh deterministic id and rewrite that input's Results/TestEntries testId references, so module-specific definitions are preserved. - Equal-roots relocation: validate references in place (drop rooted/traversal ones and any pointing at a missing/symlinked file) instead of preserving them unchecked. - CTRF environment: retain only fields all inputs agree on (dropping values that differ across CI agents) and always drop module-specific extra.testApplication/exitCode, instead of attributing the first report's environment to every test. Note: the source-skip and mid-relocation-failure reference cases were already handled in 2b7a860da (DropAllAttachmentReferences). Tests: same-id definition remap, unsuccessful-outcome merge, CTRF common/matching environment. 65 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 81 +++++- .../TrxReportEngine.Merge.cs | 233 ++++++++++++++---- .../CtrfReportMergerTests.cs | 33 ++- .../TrxReportEngineMergeTests.cs | 54 ++++ 4 files changed, 337 insertions(+), 64 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index ed16763674..5ec2bef062 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -55,6 +55,11 @@ internal static string Merge(IReadOnlyList inputReports) JsonNode? firstTool = null; int reportCount = 0; + // Collect each input's environment so shared fields can be retained and module- or agent-specific + // ones (values that differ across inputs) dropped, rather than attributing the first report's + // environment to every merged test. + var environments = new List(); + foreach (string reportJson in inputReports) { if (JsonNode.Parse(reportJson) is not JsonObject root) @@ -65,6 +70,11 @@ internal static string Merge(IReadOnlyList inputReports) first ??= root; reportCount++; + if (root["results"]?["environment"] is JsonObject environment) + { + environments.Add(environment); + } + JsonNode? results = root["results"]; if (results?["tests"] is JsonArray testArray) { @@ -176,18 +186,13 @@ internal static string Merge(IReadOnlyList inputReports) resultsObject["summary"] = summaryObject; - // The environment is taken from the first report for shared fields (OS, user, machine), but - // module-specific values under 'extra' (the producing test application and its exit code) cannot - // describe all merged modules, so they are dropped rather than misattributed. - if (first["results"]?["environment"] is JsonNode environment && environment.DeepClone() is JsonObject mergedEnvironment) + // Retain only environment fields that every input agrees on: OS/user/machine are shared when the + // merge is same-machine, but invocation-agnostic inputs can come from different CI agents, so a + // differing value would misstate the environment for most tests. Module-specific 'extra' values + // (the producing test application and its exit code) are always dropped. + if (BuildCommonEnvironment(environments) is JsonObject commonEnvironment) { - if (mergedEnvironment["extra"] is JsonObject environmentExtra) - { - environmentExtra.Remove("testApplication"); - environmentExtra.Remove("exitCode"); - } - - resultsObject["environment"] = mergedEnvironment; + resultsObject["environment"] = commonEnvironment; } resultsObject["tests"] = mergedTests; @@ -329,6 +334,60 @@ private static bool TryReadLong(JsonNode summary, string propertyName, out long return false; } + /// + /// Builds a merged environment containing only the fields that every input's environment agrees on + /// (so a value that differs across CI agents is dropped rather than attributed to all tests). The + /// module-specific extra.testApplication and extra.exitCode fields are always dropped. + /// Returns when no environment survives. + /// + private static JsonObject? BuildCommonEnvironment(IReadOnlyList environments) + { + if (environments.Count == 0) + { + return null; + } + + var merged = new JsonObject(); + foreach (KeyValuePair field in environments[0]) + { + if (field.Key == "extra") + { + continue; + } + + string firstValue = field.Value?.ToJsonString() ?? "null"; + if (environments.All(e => (e[field.Key]?.ToJsonString() ?? "null") == firstValue)) + { + merged[field.Key] = field.Value?.DeepClone(); + } + } + + if (environments[0]["extra"] is JsonObject firstExtra) + { + var extra = new JsonObject(); + foreach (KeyValuePair field in firstExtra) + { + if (field.Key is "testApplication" or "exitCode") + { + continue; + } + + string firstValue = field.Value?.ToJsonString() ?? "null"; + if (environments.All(e => e["extra"] is JsonObject extraObject && (extraObject[field.Key]?.ToJsonString() ?? "null") == firstValue)) + { + extra[field.Key] = field.Value?.DeepClone(); + } + } + + if (extra.Count > 0) + { + merged["extra"] = extra; + } + } + + return merged.Count > 0 ? merged : null; + } + /// /// Derives a stable reportId from the raw input reports so identical inputs reproduce the same /// id on every retry (RFC 018 idempotency) without a random source or reusing an input report's id. diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 5a30766dc4..1c3d390162 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -52,11 +52,13 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI var mergedTestLists = new XElement(NamespaceUri + "TestLists"); var seenTestListIds = new HashSet(StringComparer.OrdinalIgnoreCase); - // TestDefinition ids are derived deterministically from each test's UID, so the same test - // discovered in more than one input yields the same id. The TRX schema (and the producer, - // see TrxReportEngine.Results.cs) does not allow duplicate , so we keep - // only the first definition seen per id. - var seenTestDefinitionIds = new HashSet(StringComparer.OrdinalIgnoreCase); + // TestDefinition ids are derived deterministically from each test's UID (assembly file name plus + // test identity), so the same test discovered in more than one input yields the same id — but a + // multi-TFM merge can produce definitions that share an id yet differ (e.g. different storage / + // codeBase). We keep identical definitions deduplicated (the TRX schema forbids duplicate + // ), but remap a materially-different same-id definition to a fresh deterministic + // id and rewrite that input's testId references, so module-specific definitions are not lost. + var definitionsById = new Dictionary(StringComparer.OrdinalIgnoreCase); // Run-level diagnostics () and collector attachments () live // under ; carry them across so merged reports don't silently lose crash/exit @@ -72,18 +74,22 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI bool anyFailure = false; DateTimeOffset? earliestStart = null; DateTimeOffset? latestFinish = null; + int inputIndex = 0; foreach (XDocument report in inputReports) { XElement? testRun = report.Root; if (testRun is null) { + inputIndex++; continue; } - CloneChildrenInto(FindChild(testRun, "Results"), mergedResults); - CloneChildrenIntoDeduplicatedById(FindChild(testRun, "TestDefinitions"), mergedTestDefinitions, seenTestDefinitionIds); - CloneChildrenInto(FindChild(testRun, "TestEntries"), mergedTestEntries); + // Merge TestDefinitions first to build this input's id remap, then clone Results/TestEntries + // with that remap applied so their testId references resolve to the right definition. + Dictionary idRemap = MergeTestDefinitions(FindChild(testRun, "TestDefinitions"), mergedTestDefinitions, definitionsById, inputIndex); + CloneWithRemappedTestIds(FindChild(testRun, "Results"), mergedResults, idRemap); + CloneWithRemappedTestIds(FindChild(testRun, "TestEntries"), mergedTestEntries, idRemap); XElement? testLists = FindChild(testRun, "TestLists"); if (testLists is not null) @@ -101,7 +107,13 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI XElement? resultSummary = FindChild(testRun, "ResultSummary"); if (resultSummary is not null) { - if (string.Equals(resultSummary.Attribute("outcome")?.Value, "Failed", StringComparison.OrdinalIgnoreCase)) + // A successful run's summary outcome is "Completed" (or "Passed"); anything else + // (Failed, Error, Aborted, Timeout, Inconclusive, …) is an unsuccessful run and must not + // be flattened to "Completed" in the merged report. + string? outcome = resultSummary.Attribute("outcome")?.Value; + if (!RoslynString.IsNullOrEmpty(outcome) + && !string.Equals(outcome, "Completed", StringComparison.OrdinalIgnoreCase) + && !string.Equals(outcome, "Passed", StringComparison.OrdinalIgnoreCase)) { anyFailure = true; } @@ -126,6 +138,8 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI latestFinish = finish; } } + + inputIndex++; } if (counterSums.TryGetValue("failed", out long failedCount) && failedCount > 0) @@ -133,11 +147,21 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI anyFailure = true; } + if (counterSums.TryGetValue("error", out long errorCount) && errorCount > 0) + { + anyFailure = true; + } + if (counterSums.TryGetValue("timeout", out long timeoutCount) && timeoutCount > 0) { anyFailure = true; } + if (counterSums.TryGetValue("aborted", out long abortedCount) && abortedCount > 0) + { + anyFailure = true; + } + var mergedTestRun = new XElement( NamespaceUri + "TestRun", new XAttribute("id", runId), @@ -349,16 +373,18 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } // Equal roots: the source files already live at the merged deployment root under their - // original relative paths, so the original references resolve as-is. Skip relocation and - // leave them un-prefixed. + // original relative paths, so references are kept un-prefixed. Still validate them + // (dropping rooted/traversal references, and any pointing at a missing or symlinked file) + // so the path-confined contract holds even when nothing is copied. if (string.Equals(sourceInRoot, mergedInRoot, PathComparison)) { + RelocateReferencedAttachments(reports[i], prefix: string.Empty, sourceInRoot, mergedInRoot, relocate: false, cancellationToken); continue; } // Copy only the files the report references (never the whole tree), so an output nested // inside a source neither recurses into its own destination nor accumulates across retries. - RelocateReferencedAttachments(reports[i], i.ToString(CultureInfo.InvariantCulture), sourceInRoot, mergedInRoot, cancellationToken); + RelocateReferencedAttachments(reports[i], i.ToString(CultureInfo.InvariantCulture), sourceInRoot, mergedInRoot, relocate: true, cancellationToken); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { @@ -391,7 +417,7 @@ private static void DropAllAttachmentReferences(XDocument report) } } - private static void RelocateReferencedAttachments(XDocument report, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) + private static void RelocateReferencedAttachments(XDocument report, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) { if (report.Root is not { } root) { @@ -406,35 +432,39 @@ private static void RelocateReferencedAttachments(XDocument report, string prefi // In//, so the file is copied to // In/// and the *directory* is prefixed once. // Only referenced files are copied (never a whole tree), and a reference that is rooted, escapes - // the deployment root, or whose source file was not materialized is dropped. + // the deployment root, or whose source file was not materialized is dropped. When + // is false (equal-roots case: the source already lives at the merged + // deployment root) references are validated and dropped the same way, but kept un-prefixed and + // nothing is copied. // Materialize each pass first: a dropped reference is removed, and removing while enumerating the // lazy Descendants() sequence would be unsafe. foreach (XElement collectorAttachment in root.Descendants().Where(e => e.Name.LocalName == "A").ToList()) { - RelocateReference(collectorAttachment, "href", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, cancellationToken); + RelocateReference(collectorAttachment, "href", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); } foreach (XElement unitTestResult in root.Descendants().Where(e => e.Name.LocalName == "UnitTestResult").ToList()) { - RelocateResultFilesForUnitTestResult(unitTestResult, prefix, sourceInRoot, mergedInRoot, cancellationToken); + RelocateResultFilesForUnitTestResult(unitTestResult, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); } foreach (XElement standaloneResultFile in root.Descendants() .Where(e => e.Name.LocalName == "ResultFile" && e.Ancestors().All(a => a.Name.LocalName != "UnitTestResult")) .ToList()) { - RelocateReference(standaloneResultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, cancellationToken); + RelocateReference(standaloneResultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); } } /// - /// Relocates the per-test ResultFile references of a single UnitTestResult. Consumers - /// resolve them under In/<relativeResultsDirectory>/<path>, so each referenced file - /// is copied to In/<prefix>/<relativeResultsDirectory>/<path> and the - /// directory is prefixed once; a reference that is rooted, escapes the root, or whose source file was - /// not materialized is dropped. + /// Relocates (or, when is false, validates in place) the per-test + /// ResultFile references of a single UnitTestResult. Consumers resolve them under + /// In/<relativeResultsDirectory>/<path>; when relocating, each referenced file is + /// copied to In/<prefix>/<relativeResultsDirectory>/<path> and the directory + /// is prefixed once. A reference that is rooted, escapes the root, or whose source file was not + /// materialized (missing or symlinked) is dropped in either mode. /// - private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) + private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) { List resultFiles = [.. unitTestResult.Descendants().Where(e => e.Name.LocalName == "ResultFile")]; if (resultFiles.Count == 0) @@ -450,7 +480,7 @@ private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult // No owning directory: each path resolves at In/, like a collector href. foreach (XElement resultFile in resultFiles) { - RelocateReference(resultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, cancellationToken); + RelocateReference(resultFile, "path", relativeDirectory: null, prefix, sourceInRoot, mergedInRoot, relocate, cancellationToken); } return; @@ -467,14 +497,14 @@ private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult return; } - // Copy each referenced file to In///, dropping any rooted, - // escaping, or non-materialized reference, then prefix the directory once if anything survived. + // Copy (or validate) each referenced file, dropping any rooted, escaping, or non-materialized + // reference, then prefix the directory once if anything survived (only when relocating). bool anyKept = false; foreach (XElement resultFile in resultFiles) { string path = resultFile.Attribute("path")?.Value ?? string.Empty; string combined = relativeDirectoryValue + "/" + path; - if (Path.IsPathRooted(path) || EscapesRoot(combined) || !TryCopyReferencedFile(sourceInRoot, mergedInRoot, prefix, combined, cancellationToken)) + if (Path.IsPathRooted(path) || EscapesRoot(combined) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, combined, relocate, cancellationToken)) { resultFile.Remove(); } @@ -484,19 +514,20 @@ private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult } } - if (anyKept) + if (anyKept && relocate) { relativeDirectory.Value = prefix + "/" + relativeDirectoryValue; } } /// - /// Copies the file referenced by into the per-input folder and - /// prefixes the reference. A rooted value (an absolute path outside the confined deployment tree), a - /// value that escapes the deployment root, or a source file that was not materialized (missing, or a - /// skipped symlink) causes the reference to be dropped instead of emitted. + /// Relocates (or validates in place) the file referenced by . A rooted + /// value (an absolute path outside the confined deployment tree), a value that escapes the deployment + /// root, or a source file that was not materialized (missing, or a skipped symlink) causes the + /// reference to be dropped. When is true the file is copied into the + /// per-input folder and the reference is prefixed; otherwise it is left unchanged. /// - private static void RelocateReference(XElement element, string attributeName, string? relativeDirectory, string prefix, string sourceInRoot, string mergedInRoot, CancellationToken cancellationToken) + private static void RelocateReference(XElement element, string attributeName, string? relativeDirectory, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) { XAttribute? attribute = element.Attribute(attributeName); if (attribute is null || RoslynString.IsNullOrEmpty(attribute.Value)) @@ -511,37 +542,51 @@ private static void RelocateReference(XElement element, string attributeName, st } string relative = relativeDirectory is null ? attribute.Value : relativeDirectory + "/" + attribute.Value; - if (EscapesRoot(relative) || !TryCopyReferencedFile(sourceInRoot, mergedInRoot, prefix, relative, cancellationToken)) + if (EscapesRoot(relative) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, relative, relocate, cancellationToken)) { element.Remove(); return; } - attribute.Value = prefix + "/" + attribute.Value; + if (relocate) + { + attribute.Value = prefix + "/" + attribute.Value; + } } /// - /// Copies a single referenced attachment file from to the per-input - /// folder under , both confined. Returns (so the - /// caller drops the reference) when the source file is missing, is a symlink, sits behind a symlinked - /// component, or when either resolved path escapes its root. is already - /// known to be non-rooted and non-escaping. + /// Validates a single referenced attachment file under and, when + /// is true, copies it to the per-input folder under + /// . Returns (so the caller drops the reference) + /// when the source file is missing, is a symlink, sits behind a symlinked component, or when either + /// resolved path escapes its root. is already known to be non-rooted and + /// non-escaping. /// - private static bool TryCopyReferencedFile(string sourceInRoot, string mergedInRoot, string prefix, string relative, CancellationToken cancellationToken) + private static bool TryMaterializeOrValidateReference(string sourceInRoot, string mergedInRoot, string prefix, string relative, bool relocate, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); string normalized = relative.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar); string sourceFile = Path.GetFullPath(Path.Combine(sourceInRoot, normalized)); - string destinationFile = Path.GetFullPath(Path.Combine(mergedInRoot, prefix, normalized)); - // Confine both ends and refuse anything behind a symlinked component or a symlinked file, so a - // link cannot redirect the read or write outside the confined trees. + // Refuse a source that escapes its root, is missing, is a symlink, or sits behind a symlinked + // component, so a link cannot redirect the read outside the confined tree. if (!IsUnderDirectory(sourceFile, sourceInRoot) - || !IsUnderDirectory(destinationFile, mergedInRoot) || !File.Exists(sourceFile) || IsReparsePoint(sourceFile) - || HasReparsePointComponent(Path.GetDirectoryName(sourceFile)!, sourceInRoot) + || HasReparsePointComponent(Path.GetDirectoryName(sourceFile)!, sourceInRoot)) + { + return false; + } + + if (!relocate) + { + // Equal-roots: the file already lives at the merged deployment root; validated, nothing to copy. + return true; + } + + string destinationFile = Path.GetFullPath(Path.Combine(mergedInRoot, prefix, normalized)); + if (!IsUnderDirectory(destinationFile, mergedInRoot) || HasReparsePointComponent(Path.GetDirectoryName(destinationFile)!, mergedInRoot)) { return false; @@ -658,7 +703,64 @@ private static void CloneChildrenInto(XElement? source, XElement destination) } } - private static void CloneChildrenIntoDeduplicatedById(XElement? source, XElement destination, HashSet seenIds) + /// + /// Merges one input's TestDefinitions into the accumulator and returns the id remap to apply to + /// that input's testId references. An id not seen before is kept; an id whose definition is + /// identical to the one already kept is deduplicated (the schema forbids duplicate ids); an id whose + /// definition differs (e.g. the same test from another TFM with different storage) is remapped to a + /// fresh deterministic id and kept, so module-specific definitions are preserved. + /// + private static Dictionary MergeTestDefinitions(XElement? testDefinitions, XElement mergedTestDefinitions, Dictionary definitionsById, int inputIndex) + { + var remap = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (testDefinitions is null) + { + return remap; + } + + foreach (XElement definition in testDefinitions.Elements()) + { + string? id = definition.Attribute("id")?.Value; + if (id is null) + { + mergedTestDefinitions.Add(new XElement(definition)); + continue; + } + + if (!definitionsById.TryGetValue(id, out XElement? existing)) + { + definitionsById[id] = definition; + mergedTestDefinitions.Add(new XElement(definition)); + } + else if (XNode.DeepEquals(existing, definition)) + { + // Identical definition already kept — deduplicate. + } + else + { + string newId = RemapDefinitionId(id, inputIndex); + while (definitionsById.ContainsKey(newId)) + { + newId = RemapDefinitionId(newId, inputIndex); + } + + var clone = new XElement(definition); + clone.SetAttributeValue("id", newId); + definitionsById[newId] = clone; + mergedTestDefinitions.Add(clone); + remap[id] = newId; + } + } + + return remap; + } + + /// + /// Clones the children of into , rewriting any + /// testId attribute (on the child or its descendants) through so a + /// remapped TestDefinition's results/entries reference the right definition. + /// + private static void CloneWithRemappedTestIds(XElement? source, XElement destination, Dictionary remap) { if (source is null) { @@ -667,16 +769,43 @@ private static void CloneChildrenIntoDeduplicatedById(XElement? source, XElement foreach (XElement child in source.Elements()) { - string? id = child.Attribute("id")?.Value; - - // Keep the first definition seen for a given id; a null/absent id is always kept. - if (id is null || seenIds.Add(id)) + var clone = new XElement(child); + if (remap.Count > 0) { - destination.Add(new XElement(child)); + foreach (XElement element in clone.DescendantsAndSelf()) + { + if (element.Attribute("testId") is { } testId && remap.TryGetValue(testId.Value, out string? newId)) + { + testId.Value = newId; + } + } } + + destination.Add(clone); } } + /// + /// Derives a stable, distinct id for a TestDefinition that collides with a materially-different one, + /// from the original id and the input index, so the remap is deterministic (RFC 018 idempotency). + /// + private static string RemapDefinitionId(string originalId, int inputIndex) + { + const ulong fnvPrime = 1099511628211UL; + ulong low = 14695981039346656037UL; + ulong high = 0x9E3779B97F4A7C15UL; + foreach (char c in originalId + "|" + inputIndex.ToString(CultureInfo.InvariantCulture)) + { + low = (low ^ c) * fnvPrime; + high = (high ^ c) * fnvPrime; + } + + byte[] bytes = new byte[16]; + BitConverter.GetBytes(low).CopyTo(bytes, 0); + BitConverter.GetBytes(high).CopyTo(bytes, 8); + return new Guid(bytes).ToString(); + } + private static void AccumulateCounters(XElement? counters, List attributeOrder, Dictionary sums) { if (counters is null) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index ecaa9650f1..b71564b68d 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -253,6 +253,36 @@ private static bool TryCreateDirectorySymlink(string linkPath, string targetPath } #endif + [TestMethod] + public void Merge_WhenEnvironmentsDiffer_RetainsCommonFieldsAndDropsDiffering() + { + // Two inputs from different CI agents disagree on osPlatform but share user/machine. The merged + // environment must drop the differing osPlatform, keep the common extra fields, and always drop + // the module-specific testApplication/exitCode. + string a = BuildReport(osPlatform: "linux"); + string b = BuildReport(osPlatform: "windows"); + + JsonNode environment = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["environment"]!; + + Assert.IsNull(environment["osPlatform"]); + var extra = (JsonObject)environment["extra"]!; + Assert.AreEqual("someone", (string?)extra["user"]); + Assert.AreEqual("box", (string?)extra["machine"]); + Assert.IsFalse(extra.ContainsKey("testApplication")); + Assert.IsFalse(extra.ContainsKey("exitCode")); + } + + [TestMethod] + public void Merge_WhenEnvironmentsMatch_RetainsSharedFields() + { + string a = BuildReport(osPlatform: "linux"); + string b = BuildReport(osPlatform: "linux"); + + JsonNode environment = JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["results"]!["environment"]!; + + Assert.AreEqual("linux", (string?)environment["osPlatform"]); + } + private static JsonObject Test(string name, string status) => new() { @@ -273,6 +303,7 @@ private static string BuildReport( long stop = 2000, string toolName = "MSTest", string? toolVersion = null, + string osPlatform = "test", IEnumerable? testEntries = null) { var testArray = new JsonArray(); @@ -312,7 +343,7 @@ private static string BuildReport( }, ["environment"] = new JsonObject { - ["osPlatform"] = "test", + ["osPlatform"] = osPlatform, ["extra"] = new JsonObject { ["user"] = "someone", diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index f02874d3e1..9f82135e50 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -110,6 +110,60 @@ public void Merge_UnionsTestDefinitionsAndEntries() Assert.HasCount(2, Child(root, "TestEntries").Elements()); } + [TestMethod] + public void Merge_WhenSameIdDefinitionsDiffer_RemapsAndRewritesReferences() + { + // A multi-TFM merge can produce two TestDefinitions that share an id but differ (e.g. different + // storage). The merge must keep both — remapping the later one to a fresh id and rewriting its + // results/entries — rather than dropping the module-specific definition. + XElement defA = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("storage", "a.dll"), new XAttribute("name", "SharedTest")); + XElement defB = new(Ns + "UnitTest", new XAttribute("id", "t1"), new XAttribute("storage", "b.dll"), new XAttribute("name", "SharedTest")); + XDocument a = BuildReport( + testDefinitions: [defA], + results: [new XElement(Ns + "UnitTestResult", new XAttribute("testId", "t1"), new XAttribute("executionId", "e1"))], + testEntries: [new XElement(Ns + "TestEntry", new XAttribute("testId", "t1"), new XAttribute("executionId", "e1"))]); + XDocument b = BuildReport( + testDefinitions: [defB], + results: [new XElement(Ns + "UnitTestResult", new XAttribute("testId", "t1"), new XAttribute("executionId", "e2"))], + testEntries: [new XElement(Ns + "TestEntry", new XAttribute("testId", "t1"), new XAttribute("executionId", "e2"))]); + + XElement root = TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!; + + // Both definitions survive with distinct ids and both storages are present. + List definitions = [.. Child(root, "TestDefinitions").Elements()]; + Assert.HasCount(2, definitions); + List ids = [.. definitions.Select(e => e.Attribute("id")!.Value)]; + Assert.HasCount(2, ids.Distinct()); + List storages = [.. definitions.Select(e => e.Attribute("storage")!.Value)]; + Assert.Contains("a.dll", storages); + Assert.Contains("b.dll", storages); + + // Every result/entry testId must reference a real definition id (the second input's was remapped). + var definitionIds = new HashSet(ids); + foreach (XElement result in Child(root, "Results").Elements()) + { + Assert.Contains(result.Attribute("testId")!.Value, definitionIds); + } + + foreach (XElement entry in Child(root, "TestEntries").Elements()) + { + Assert.Contains(entry.Attribute("testId")!.Value, definitionIds); + } + } + + [TestMethod] + public void Merge_WhenAnInputHasUnsuccessfulOutcome_MergedOutcomeIsFailed() + { + // A TRX summary outcome of "Error" (not just "Failed") is an unsuccessful run and must not be + // flattened to "Completed" in the merged report. + XDocument a = BuildReport(outcome: "Completed"); + XDocument b = BuildReport(outcome: "Error"); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + Assert.AreEqual("Failed", summary.Attribute("outcome")!.Value); + } + [TestMethod] public void Merge_DeduplicatesTestDefinitionsById() { From d582d47694b02e2324f4ce2d11bbe7192c9f24f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 20:00:47 +0200 Subject: [PATCH 19/26] Fix UriAttachment schema validity, dangling dest symlinks, netstandard alias fail-closed (RFC 018 review) - Dropping a collector attachment now removes its owning (the TRX schema requires exactly one child), so a dropped reference never leaves an invalid empty . A is still removed alone (empty is valid). Applied to every drop path via RemoveReferenceElement. - Destination symlink removal now detects a DANGLING link entry (File.Exists follows the link and reports it missing) via FileInfo.LinkTarget, so File.Copy can no longer follow a dangling destination link and write outside the merged root. - Output aliasing on netstandard/.NET Framework (no link-resolution API): fail closed when any existing ancestor of the output path is a reparse point, so a symlinked parent that could alias an input directory cannot let the write replace the input. Applied to TRX/JUnit/CTRF. Tests: rooted-href drop now asserts no empty remains. 65 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 33 +++++++ .../JUnitReportMerger.cs | 33 +++++++ .../TrxReportEngine.Merge.cs | 88 +++++++++++++++++-- .../TrxReportEngineMergeTests.cs | 6 +- 4 files changed, 152 insertions(+), 8 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 5ec2bef062..1dd9e6d27e 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -428,6 +428,15 @@ private static string CreateDeterministicReportId(IReadOnlyList inputRep /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { +#if !NETCOREAPP + // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that + // aliases an input directory would slip past the textual comparison below and let the write + // replace the input. Fail closed when any existing ancestor of the output is a reparse point. + if (HasReparsePointAncestor(outputPath)) + { + throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); + } +#endif string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { @@ -438,6 +447,30 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat } } +#if !NETCOREAPP + private static bool HasReparsePointAncestor(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return false; + } +#endif + /// /// Canonicalizes to a full path with symlinks/junctions resolved in every /// existing component (so a symlinked parent directory that aliases another location is detected). On diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index 9f673854e0..5a6dffcb91 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -234,6 +234,15 @@ private static bool TryReadTimestamp(XElement element, string attributeName, out /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { +#if !NETCOREAPP + // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that + // aliases an input directory would slip past the textual comparison below and let the write + // replace the input. Fail closed when any existing ancestor of the output is a reparse point. + if (HasReparsePointAncestor(outputPath)) + { + throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); + } +#endif string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { @@ -244,6 +253,30 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat } } +#if !NETCOREAPP + private static bool HasReparsePointAncestor(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return false; + } +#endif + /// /// Canonicalizes to a full path with symlinks/junctions resolved in every /// existing component (so a symlinked parent directory that aliases another location is detected). On diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 1c3d390162..4290584ae4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -412,6 +412,24 @@ private static void DropAllAttachmentReferences(XDocument report) } foreach (XElement element in root.Descendants().Where(e => e.Name.LocalName is "A" or "ResultFile").ToList()) + { + RemoveReferenceElement(element); + } + } + + /// + /// Removes a dropped attachment reference. A collector <A> takes its owning + /// <UriAttachment> with it (the TRX schema requires exactly one <A> child, so + /// leaving an empty <UriAttachment> would be invalid); a <ResultFile> is + /// removed alone (an empty <ResultFiles> is schema-valid). + /// + private static void RemoveReferenceElement(XElement element) + { + if (element.Name.LocalName == "A" && element.Parent is { } parent && string.Equals(parent.Name.LocalName, "UriAttachment", StringComparison.Ordinal)) + { + parent.Remove(); + } + else { element.Remove(); } @@ -537,14 +555,14 @@ private static void RelocateReference(XElement element, string attributeName, st if (Path.IsPathRooted(attribute.Value)) { - element.Remove(); + RemoveReferenceElement(element); return; } string relative = relativeDirectory is null ? attribute.Value : relativeDirectory + "/" + attribute.Value; if (EscapesRoot(relative) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, relative, relocate, cancellationToken)) { - element.Remove(); + RemoveReferenceElement(element); return; } @@ -595,16 +613,39 @@ private static bool TryMaterializeOrValidateReference(string sourceInRoot, strin Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); // A pre-existing destination symlink (from a reused output directory) would make the overwrite - // follow the link and write outside the merged root. Remove it so a real file is written. - if (File.Exists(destinationFile) && IsReparsePoint(destinationFile)) - { - File.Delete(destinationFile); - } + // follow the link and write outside the merged root. Remove the link entry — including a DANGLING + // one, which File.Exists reports as missing (it follows the link) — so File.Copy writes a real file. + RemoveSymlinkEntry(destinationFile); File.Copy(sourceFile, destinationFile, overwrite: true); return true; } + /// + /// Removes a symbolic-link/junction directory entry at , whether or not its + /// target still exists (a dangling link). Unlike (which follows the + /// link and reports a dangling one as missing), this detects the link entry itself, so a later + /// writes a real file instead of following the link out + /// of the confined root. A real file or a missing entry is left untouched. + /// + private static void RemoveSymlinkEntry(string path) + { +#if NETCOREAPP + // FileInfo.LinkTarget is non-null for a link entry even when the target is missing; Exists is + // target-based, so a dangling link has Exists == false but LinkTarget != null. + var info = new FileInfo(path); + if (info.LinkTarget is not null) + { + info.Delete(); + } +#else + if (File.Exists(path) && IsReparsePoint(path)) + { + File.Delete(path); + } +#endif + } + /// /// Returns if the relative resolves above its /// own root (i.e. a .. segment pops past the start), meaning it would escape the confined @@ -888,6 +929,15 @@ private static Guid CreateDeterministicSettingsId(Guid runId) /// private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) { +#if !NETCOREAPP + // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that + // aliases an input directory would slip past the textual comparison below and let the write + // replace the input. Fail closed when any existing ancestor of the output is a reparse point. + if (HasReparsePointAncestor(outputPath)) + { + throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); + } +#endif string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { @@ -898,6 +948,30 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat } } +#if !NETCOREAPP + private static bool HasReparsePointAncestor(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current) && IsReparsePoint(current)) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return false; + } +#endif + /// /// Canonicalizes to a full path with symlinks/junctions resolved in every /// existing component (so a symlinked parent directory that aliases another location is detected). On diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 9f82135e50..e4d5550a51 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -426,8 +426,12 @@ public async Task MergeToFileAsync_WhenAttachmentHrefIsRooted_DropsTheReference( await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); - List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + var mergedDoc = XDocument.Load(output); + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; Assert.IsEmpty(hrefs); + // Dropping the must also remove its owning so no schema-invalid empty + // element is left behind. + Assert.IsEmpty(mergedDoc.Descendants().Where(e => e.Name.LocalName == "UriAttachment")); } finally { From ecdc9f4b3d8f1a2d782fd24eed5357be7dbb06cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 20:25:10 +0200 Subject: [PATCH 20/26] Harden path containment, hardlink copy, top-level bail, and env unanimity (RFC 018 review) - Path containment is now ordinal (case-sensitive): a case-insensitive IsUnderDirectory would, on a case-sensitive filesystem, accept a hostile '../foo' deployment root as confined under an input at '/tmp/Foo' and read the case-distinct sibling '/tmp/foo'. The output-alias EQUALITY check stays case-insensitive on canonicalized paths, independently. - Destination copy: delete any pre-existing entry (regular file, symlink, dangling symlink, or hardlink) then copy WITHOUT overwrite, so a reused output tree can no longer make File.Copy follow a link or overwrite a hardlink's shared target outside the merged root. - Top-level relocation bail: when the merged deployment root can't be set up safely (escapes the output dir, a reparse-point component, or an unremovable pre-existing 'In' link) drop EVERY input's references before returning, so none resolve through the unsafe root. - CTRF environment: only emit a field if every accepted report supplied an environment (a report with none is a disagreement), so a missing environment no longer lets the first report's OS/user/machine describe all tests. Tests: escape test now asserts the relocated copy is confined to the output directory; CTRF missing-environment drop. 66 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 14 ++-- .../TrxReportEngine.Merge.cs | 67 +++++++++++++------ .../CtrfReportMergerTests.cs | 13 ++++ .../TrxReportEngineMergeTests.cs | 19 +++--- 4 files changed, 78 insertions(+), 35 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 1dd9e6d27e..07057300e2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -188,9 +188,11 @@ internal static string Merge(IReadOnlyList inputReports) // Retain only environment fields that every input agrees on: OS/user/machine are shared when the // merge is same-machine, but invocation-agnostic inputs can come from different CI agents, so a - // differing value would misstate the environment for most tests. Module-specific 'extra' values - // (the producing test application and its exit code) are always dropped. - if (BuildCommonEnvironment(environments) is JsonObject commonEnvironment) + // differing value would misstate the environment for most tests. A report that supplies no + // environment at all counts as disagreement (its fields are absent), so a common field requires + // every accepted report to provide it. Module-specific 'extra' values (the producing test + // application and its exit code) are always dropped. + if (BuildCommonEnvironment(environments, reportCount) is JsonObject commonEnvironment) { resultsObject["environment"] = commonEnvironment; } @@ -340,9 +342,11 @@ private static bool TryReadLong(JsonNode summary, string propertyName, out long /// module-specific extra.testApplication and extra.exitCode fields are always dropped. /// Returns when no environment survives. /// - private static JsonObject? BuildCommonEnvironment(IReadOnlyList environments) + private static JsonObject? BuildCommonEnvironment(IReadOnlyList environments, int reportCount) { - if (environments.Count == 0) + // A field can only be common to all inputs if every accepted report supplied an environment; a + // report with no environment is a disagreement (the field is absent there). + if (environments.Count == 0 || environments.Count != reportCount) { return null; } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 4290584ae4..9387fc1fbd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -310,9 +310,12 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO // The confined leaf can no longer be '.' or '..', but keep the defensive lexical/reparse checks: // reject if the deployment root escapes the output directory or any component below it is a - // reparse point (a symlink/junction there would resolve writes outside the confined root). + // reparse point (a symlink/junction there would resolve writes outside the confined root). We + // cannot set up a safe merged root, so drop every input's references before bailing — otherwise + // they would be emitted unchanged and resolve through the unsafe root. if (!IsUnderDirectory(mergedRootFull, outputFull) || HasReparsePointComponent(mergedRootFull, outputFull)) { + DropAllReferences(reports); return; } @@ -329,6 +332,8 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { + // The unsafe link remains; drop every input's references so none resolve through it. + DropAllReferences(reports); return; } } @@ -398,6 +403,19 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO } } + /// + /// Removes every attachment reference from all reports. Used when the merged deployment root cannot be + /// set up safely and the whole relocation is abandoned, so no report keeps references that would + /// resolve through the unsafe root. + /// + private static void DropAllReferences(IReadOnlyList reports) + { + foreach (XDocument report in reports) + { + DropAllAttachmentReferences(report); + } + } + /// /// Removes every attachment reference (<A> and <ResultFile>) from a report. /// Used when an input's attachments cannot be relocated (missing/invalid source, or a failed copy), so @@ -612,34 +630,35 @@ private static bool TryMaterializeOrValidateReference(string sourceInRoot, strin Directory.CreateDirectory(Path.GetDirectoryName(destinationFile)!); - // A pre-existing destination symlink (from a reused output directory) would make the overwrite - // follow the link and write outside the merged root. Remove the link entry — including a DANGLING - // one, which File.Exists reports as missing (it follows the link) — so File.Copy writes a real file. - RemoveSymlinkEntry(destinationFile); + // Remove ANY pre-existing destination entry — a regular file, a symlink (including a DANGLING one, + // which File.Exists reports as missing because it follows the link), or a hardlink — then copy + // WITHOUT overwrite. This guarantees a fresh file is written rather than a link being followed, or a + // hardlink's shared target (which lives outside the merged root) being overwritten in place. + DeleteDestinationEntry(destinationFile); - File.Copy(sourceFile, destinationFile, overwrite: true); + File.Copy(sourceFile, destinationFile, overwrite: false); return true; } /// - /// Removes a symbolic-link/junction directory entry at , whether or not its - /// target still exists (a dangling link). Unlike (which follows the - /// link and reports a dangling one as missing), this detects the link entry itself, so a later - /// writes a real file instead of following the link out - /// of the confined root. A real file or a missing entry is left untouched. + /// Deletes any existing directory entry at — a regular file, a symlink/junction + /// (including a dangling one, which reports as missing because it + /// follows the link), or a hardlink — so a subsequent copy writes a fresh file instead of following a + /// link out of, or overwriting a hardlink's shared target outside, the confined root. A missing entry + /// is left untouched. /// - private static void RemoveSymlinkEntry(string path) + private static void DeleteDestinationEntry(string path) { #if NETCOREAPP - // FileInfo.LinkTarget is non-null for a link entry even when the target is missing; Exists is - // target-based, so a dangling link has Exists == false but LinkTarget != null. + // FileInfo.Exists is target-based (a dangling link reads as not-existing) while LinkTarget is + // non-null for any link entry, so together they cover regular files, hardlinks, and (dangling) links. var info = new FileInfo(path); - if (info.LinkTarget is not null) + if (info.Exists || info.LinkTarget is not null) { info.Delete(); } #else - if (File.Exists(path) && IsReparsePoint(path)) + if (File.Exists(path)) { File.Delete(path); } @@ -724,12 +743,16 @@ private static bool HasReparsePointComponent(string candidateFullPath, string ba return false; } - // Path comparisons here gate destructive/aliasing decisions (equal-roots skips, overlap detection, - // output-alias rejection). Windows and the default macOS volume are case-insensitive, and even on a - // case-sensitive Linux volume treating two case-differing paths as the same only makes these guards - // MORE conservative (skip relocation / reject an output) — never less safe. So compare - // case-insensitively on every platform rather than guessing case sensitivity from the OS name. - private static StringComparison PathComparison => StringComparison.OrdinalIgnoreCase; + // PathComparison gates *containment* decisions (IsUnderDirectory / HasReparsePointComponent confining + // a source or destination under a root, and the equal-roots skip). Containment MUST be ordinal + // (case-sensitive): on a case-sensitive filesystem a hostile deployment root such as '../foo' resolves + // to the case-distinct sibling '/tmp/foo' of an input under '/tmp/Foo', and a case-insensitive check + // would wrongly accept it as confined and read the sibling's attachments. Treating case-distinct paths + // as different only makes containment MORE restrictive (skip/drop) — never an escape. + // + // The output-alias EQUALITY check is a different concern (two names for the SAME file) and is compared + // case-insensitively on canonicalized paths in EnsureOutputDoesNotAliasInput, independently of this. + private static StringComparison PathComparison => StringComparison.Ordinal; private static void CloneChildrenInto(XElement? source, XElement destination) { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index b71564b68d..3af57e0150 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -359,6 +359,19 @@ private static string BuildReport( return report.ToJsonString(); } + [TestMethod] + public void Merge_WhenAnInputHasNoEnvironment_DropsEnvironment() + { + // One input supplies no environment at all. Its OS/user/machine are unknown, so no field is shared + // by every input and the merged report must not attribute the other input's environment to it. + string withEnvironment = BuildReport(osPlatform: "linux"); + string withoutEnvironment = BuildReportWithoutSummary(Test("t", "passed")); + + JsonNode results = JsonNode.Parse(CtrfReportMerger.Merge([withEnvironment, withoutEnvironment]))!["results"]!; + + Assert.IsNull(results["environment"]); + } + private static string BuildReportWithoutSummary(params JsonObject[] testEntries) { var testArray = new JsonArray(); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index e4d5550a51..7e6dbef82a 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -736,16 +736,19 @@ public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfine Assert.IsTrue(File.Exists(output)); // The merged TRX must declare a confined deployment root (never ".."). - string? deploymentRoot = XDocument.Load(output).Descendants() + var mergedDoc = XDocument.Load(output); + string? deploymentRoot = mergedDoc.Descendants() .FirstOrDefault(e => e.Name.LocalName == "Deployment")?.Attribute("runDeploymentRoot")?.Value; Assert.AreEqual("_..", deploymentRoot); - - // Every physical attachment must remain under the output directory (never in its parent). - List attachmentCopies = [.. Directory.GetFiles(tempDirectory, "log.txt", SearchOption.AllDirectories)]; - foreach (string copy in attachmentCopies) - { - Assert.StartsWith(tempDirectory, copy); - } + Assert.HasCount(1, mergedDoc.Descendants().Where(e => e.Name.LocalName == "A")); + + // The relocated attachment must live under the OUTPUT directory (confined by the "_.." leaf), + // never escaping it — asserting confinement to the output dir, not merely the temp root. + string outputDirectory = Path.GetFullPath(Path.Combine(tempDirectory, "out")); + List relocatedCopies = [.. Directory.GetFiles(outputDirectory, "log.txt", SearchOption.AllDirectories)]; + Assert.HasCount(1, relocatedCopies); + Assert.StartsWith(outputDirectory + Path.DirectorySeparatorChar, Path.GetFullPath(relocatedCopies[0])); + Assert.AreEqual("AAA", File.ReadAllText(relocatedCopies[0])); } finally { From e68ed4207b53bec2690480a5b55d9b69e2c2a0e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 21:03:40 +0200 Subject: [PATCH 21/26] Preserve rooted attachments (RFC 018), normalize separators, validate CTRF inputs (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rooted (absolute) attachment references are now PRESERVED unchanged (RFC 018 keeps absolute TRX paths resolvable), for collector , standalone , and per-test paths (and a rooted relativeResultsDirectory). Only relative traversal or non-materialized references are dropped. - Cross-platform separators: relocated references are emitted with '/' (normalizing any '\\'), matching the separator-normalized copy destination, so a Windows-produced TRX merged on Unix (or vice versa) resolves. - ReplaceFile deletes the output entry unconditionally (File.Delete is a no-op when nothing exists) so a DANGLING output symlink — which File.Exists reports as missing yet still blocks File.Move — is replaced. Applied to TRX/JUnit/CTRF. - CTRF input validation: skip any JSON object that is not a CTRF document (no results object, or a non-CTRF reportFormat) so non-CTRF data is never emitted under a CTRF label. And stamp the merger's own identity into 'generatedBy' rather than carrying the first input's. Tests: rooted-href now asserted preserved; empty-UriAttachment assertion moved to the escaping-drop test; dedup remap now asserts each executionId maps to its own storage; CTRF generatedBy stamp and non-CTRF-input skip. 68 merge tests pass; full build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 43 +++++++++---- .../JUnitReportMerger.cs | 14 ++--- .../TrxReportEngine.Merge.cs | 62 +++++++++++-------- .../CtrfReportMergerTests.cs | 40 ++++++++++++ .../TrxReportEngineMergeTests.cs | 42 +++++++------ 5 files changed, 137 insertions(+), 64 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 07057300e2..7fff9071a1 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -28,6 +28,10 @@ internal static class CtrfReportMerger // merged report does not misattribute one framework's identity to another's tests. private const string MergedToolName = "Microsoft.Testing.Extensions.CtrfReport (merged)"; + // Identity stamped into the merged document's 'generatedBy' — the merge is produced by this extension, + // not by any input report. + private const string GeneratedByName = "Microsoft.Testing.Extensions.CtrfReport"; + internal static string Merge(IReadOnlyList inputReports) { if (inputReports is null) @@ -67,6 +71,22 @@ internal static string Merge(IReadOnlyList inputReports) continue; } + // Only accept genuine CTRF documents: a non-CTRF JSON object (different reportFormat, or no + // results object) must not become 'first' and have CTRF-shaped data emitted under its label. + if (root["results"] is not JsonObject) + { + continue; + } + + if (root["reportFormat"] is JsonNode reportFormat) + { + string? format = reportFormat is JsonValue formatValue && formatValue.TryGetValue(out string? formatText) ? formatText : null; + if (!string.Equals(format, "CTRF", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + } + first ??= root; reportCount++; @@ -205,14 +225,13 @@ internal static string Merge(IReadOnlyList inputReports) ["specVersion"] = first["specVersion"]?.DeepClone() ?? "0.0.0", ["reportId"] = CreateDeterministicReportId(inputReports), ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stopMs).ToString("O", CultureInfo.InvariantCulture), + // The merged document is produced by this merger, not by any input, so stamp its own identity + // rather than carrying the first input's 'generatedBy' (which could report a different producer + // or version when merging reports from different tool versions). + ["generatedBy"] = GeneratedByName, ["results"] = resultsObject, }; - if (first["generatedBy"] is JsonNode generatedBy) - { - merged["generatedBy"] = generatedBy.DeepClone(); - } - return merged.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); } @@ -285,14 +304,12 @@ private static string GetTempSiblingPath(string outputPath) private static void ReplaceFile(string tempPath, string outputPath) { - // Delete the destination entry (a regular file, or a symlink/hardlink alias) before moving the - // freshly written temp file into place. Deleting a link removes only the link, never its target's - // content, so a source aliased by the output path is never truncated. An exact (case-insensitive) - // alias of an input has already been rejected, so this cannot delete an input in place. - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } + // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias + // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and + // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link + // removes only the link (never its target's content); an exact (case-insensitive) alias of an + // input has already been rejected, so this cannot delete an input in place. + File.Delete(outputPath); File.Move(tempPath, outputPath); } diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index 5a6dffcb91..452d53c2f7 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -176,14 +176,12 @@ private static string GetTempSiblingPath(string outputPath) private static void ReplaceFile(string tempPath, string outputPath) { - // Delete the destination entry (a regular file, or a symlink/hardlink alias) before moving the - // freshly written temp file into place. Deleting a link removes only the link, never its target's - // content, so a source aliased by the output path is never truncated. An exact (case-insensitive) - // alias of an input has already been rejected, so this cannot delete an input in place. - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } + // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias + // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and + // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link + // removes only the link (never its target's content); an exact (case-insensitive) alias of an + // input has already been rejected, so this cannot delete an input in place. + File.Delete(outputPath); File.Move(tempPath, outputPath); } diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 9387fc1fbd..42135c5fc3 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -260,14 +260,12 @@ private static string GetTempSiblingPath(string outputPath) private static void ReplaceFile(string tempPath, string outputPath) { - // Delete the destination entry (a regular file, or a symlink/hardlink alias) before moving the - // freshly written temp file into place. Deleting a link removes only the link, never its target's - // content, so a source aliased by the output path is never truncated. An exact (case-insensitive) - // alias of an input has already been rejected, so this cannot delete an input in place. - if (File.Exists(outputPath)) - { - File.Delete(outputPath); - } + // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias + // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and + // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link + // removes only the link (never its target's content); an exact (case-insensitive) alias of an + // input has already been rejected, so this cannot delete an input in place. + File.Delete(outputPath); File.Move(tempPath, outputPath); } @@ -522,46 +520,57 @@ private static void RelocateResultFilesForUnitTestResult(XElement unitTestResult return; } - // A rooted owning directory cannot be safely relocated; drop all its references. + // A rooted owning directory makes every ResultFile resolve to an absolute path; RFC 018 keeps + // absolute attachment paths resolvable, so preserve them unchanged (not relocated, not dropped). if (Path.IsPathRooted(relativeDirectoryValue)) { - foreach (XElement resultFile in resultFiles) - { - resultFile.Remove(); - } - return; } - // Copy (or validate) each referenced file, dropping any rooted, escaping, or non-materialized - // reference, then prefix the directory once if anything survived (only when relocating). + // Copy (or validate) each referenced file. A rooted per-test path is absolute and preserved + // unchanged; a relative one that escapes the root or is not materialized is dropped; the rest are + // relocated. Prefix the directory once if any relative reference survived (only when relocating). bool anyKept = false; foreach (XElement resultFile in resultFiles) { - string path = resultFile.Attribute("path")?.Value ?? string.Empty; + XAttribute? pathAttribute = resultFile.Attribute("path"); + string path = pathAttribute?.Value ?? string.Empty; + if (Path.IsPathRooted(path)) + { + // Absolute path: preserved as-is, independent of the (relative) owning directory. + continue; + } + string combined = relativeDirectoryValue + "/" + path; - if (Path.IsPathRooted(path) || EscapesRoot(combined) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, combined, relocate, cancellationToken)) + if (EscapesRoot(combined) || !TryMaterializeOrValidateReference(sourceInRoot, mergedInRoot, prefix, combined, relocate, cancellationToken)) { resultFile.Remove(); } else { anyKept = true; + if (relocate && pathAttribute is not null) + { + // Normalize the kept path's separators to '/' so the reference resolves cross-platform, + // matching the separator-normalized copy destination. + pathAttribute.Value = path.Replace('\\', '/'); + } } } if (anyKept && relocate) { - relativeDirectory.Value = prefix + "/" + relativeDirectoryValue; + relativeDirectory.Value = prefix + "/" + relativeDirectoryValue.Replace('\\', '/'); } } /// /// Relocates (or validates in place) the file referenced by . A rooted - /// value (an absolute path outside the confined deployment tree), a value that escapes the deployment - /// root, or a source file that was not materialized (missing, or a skipped symlink) causes the - /// reference to be dropped. When is true the file is copied into the - /// per-input folder and the reference is prefixed; otherwise it is left unchanged. + /// (absolute) value is preserved unchanged — RFC 018 keeps absolute attachment paths resolvable — while + /// a value that escapes the deployment root, or a source file that was not materialized (missing, or a + /// skipped symlink), causes the reference to be dropped. When is true a + /// kept relative file is copied into the per-input folder and the reference is prefixed (separators + /// normalized to '/'); otherwise it is left unchanged. /// private static void RelocateReference(XElement element, string attributeName, string? relativeDirectory, string prefix, string sourceInRoot, string mergedInRoot, bool relocate, CancellationToken cancellationToken) { @@ -571,9 +580,10 @@ private static void RelocateReference(XElement element, string attributeName, st return; } + // RFC 018 requires attachment URIs to survive merging and treats current absolute TRX paths as + // already resolvable, so a rooted value is preserved as-is (not relocated, not dropped). if (Path.IsPathRooted(attribute.Value)) { - RemoveReferenceElement(element); return; } @@ -586,7 +596,9 @@ private static void RelocateReference(XElement element, string attributeName, st if (relocate) { - attribute.Value = prefix + "/" + attribute.Value; + // Emit with forward slashes so a reference relocated from a Windows-produced TRX resolves on + // Unix (and vice versa), matching the separator-normalized copy destination. + attribute.Value = prefix + "/" + attribute.Value.Replace('\\', '/'); } } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index 3af57e0150..def0ab1313 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -283,6 +283,46 @@ public void Merge_WhenEnvironmentsMatch_RetainsSharedFields() Assert.AreEqual("linux", (string?)environment["osPlatform"]); } + [TestMethod] + public void Merge_StampsMergerIdentityInGeneratedBy() + { + // The merged document is produced by this merger, so 'generatedBy' must be the merger's identity, + // not the (possibly different-versioned) first input's value. + var report = new JsonObject + { + ["reportFormat"] = "CTRF", + ["specVersion"] = "0.0.0", + ["generatedBy"] = "SomeOtherTool v9", + ["results"] = new JsonObject + { + ["tool"] = new JsonObject { ["name"] = "MSTest" }, + ["tests"] = new JsonArray { Test("t", "passed") }, + }, + }; + + string generatedBy = (string)JsonNode.Parse(CtrfReportMerger.Merge([report.ToJsonString()]))!["generatedBy"]!; + + Assert.AreEqual("Microsoft.Testing.Extensions.CtrfReport", generatedBy); + } + + [TestMethod] + public void Merge_IgnoresNonCtrfInputs() + { + // A JSON object that is not a CTRF document must not be accepted (become 'first') and have + // CTRF-shaped data emitted under its label; its tests are excluded from the merge. + string ctrf = BuildReport(testEntries: [Test("a", "passed")]); + var notCtrf = new JsonObject + { + ["reportFormat"] = "JUnit", + ["results"] = new JsonObject { ["tests"] = new JsonArray { Test("x", "passed") } }, + }; + + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([ctrf, notCtrf.ToJsonString()]))!; + + Assert.AreEqual("CTRF", (string?)merged["reportFormat"]); + Assert.AreEqual(1, (long)merged["results"]!["summary"]!["tests"]!); + } + private static JsonObject Test(string name, string status) => new() { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 7e6dbef82a..46d00e0976 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -138,17 +138,22 @@ public void Merge_WhenSameIdDefinitionsDiffer_RemapsAndRewritesReferences() Assert.Contains("a.dll", storages); Assert.Contains("b.dll", storages); - // Every result/entry testId must reference a real definition id (the second input's was remapped). - var definitionIds = new HashSet(ids); - foreach (XElement result in Child(root, "Results").Elements()) - { - Assert.Contains(result.Attribute("testId")!.Value, definitionIds); - } + // Every result/entry testId must reference a real definition id, and — critically — each + // execution must map to the definition with ITS OWN storage: e1 -> a.dll, e2 -> b.dll. This fails + // if the remap did not rewrite the second input's references (both would resolve to a.dll). + var storageByDefinitionId = definitions.ToDictionary(e => e.Attribute("id")!.Value, e => e.Attribute("storage")!.Value); - foreach (XElement entry in Child(root, "TestEntries").Elements()) + string StorageForExecution(string containerName, string executionId) { - Assert.Contains(entry.Attribute("testId")!.Value, definitionIds); + XElement element = Child(root, containerName).Elements() + .First(e => e.Attribute("executionId")!.Value == executionId); + return storageByDefinitionId[element.Attribute("testId")!.Value]; } + + Assert.AreEqual("a.dll", StorageForExecution("Results", "e1")); + Assert.AreEqual("b.dll", StorageForExecution("Results", "e2")); + Assert.AreEqual("a.dll", StorageForExecution("TestEntries", "e1")); + Assert.AreEqual("b.dll", StorageForExecution("TestEntries", "e2")); } [TestMethod] @@ -393,14 +398,14 @@ await Assert.ThrowsExactlyAsync( } [TestMethod] - public async Task MergeToFileAsync_WhenAttachmentHrefIsRooted_DropsTheReference() + public async Task MergeToFileAsync_WhenAttachmentHrefIsRooted_PreservesTheReference() { string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDirectory); try { - // A rooted (absolute) href points outside the confined deployment tree; the path-confined - // merge must drop it rather than preserve an absolute path to some file on disk. + // RFC 018 keeps absolute (rooted) attachment paths resolvable, so a rooted href is preserved + // unchanged rather than relocated or dropped. string inputDir = Path.Combine(tempDirectory, "in"); Directory.CreateDirectory(Path.Combine(inputDir, "dep", "In", "machine")); @@ -426,12 +431,9 @@ public async Task MergeToFileAsync_WhenAttachmentHrefIsRooted_DropsTheReference( await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); - var mergedDoc = XDocument.Load(output); - List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; - Assert.IsEmpty(hrefs); - // Dropping the must also remove its owning so no schema-invalid empty - // element is left behind. - Assert.IsEmpty(mergedDoc.Descendants().Where(e => e.Name.LocalName == "UriAttachment")); + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + Assert.AreEqual(rootedHref, hrefs[0]); } finally { @@ -477,8 +479,12 @@ public async Task MergeToFileAsync_WhenAttachmentHrefEscapesRoot_DropsTheReferen await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); - List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + var mergedDoc = XDocument.Load(output); + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; Assert.IsEmpty(hrefs); + // Dropping the escaping must also remove its owning so no schema-invalid + // empty element is left behind. + Assert.IsEmpty(mergedDoc.Descendants().Where(e => e.Name.LocalName == "UriAttachment")); } finally { From 6cdc1b1baf66fe3b2c6d1ae885b1b4542b0079d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Wed, 15 Jul 2026 21:24:53 +0200 Subject: [PATCH 22/26] Require CTRF reportFormat, carry run-level ResultFiles, preserve rooted refs on bail (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CTRF input validation: reportFormat is the required format discriminator, so an input is only accepted when it has an explicit reportFormat == 'CTRF' (case-insensitive) and a results object. A JSON object that omits reportFormat is no longer relabeled CTRF. - TRX Merge now carries run-level / (produced by VSTest) across, alongside /, so relocated run-level result-file references are not silently lost. - DropAllAttachmentReferences now preserves ROOTED references (rooted href/path, or a ResultFile whose owning UnitTestResult has a rooted relativeResultsDirectory): they resolve independently of the deployment root (RFC 018), so abandoning relocation must not delete them — only relative references are dropped. Tests: run-level ResultFiles carried; source-missing drops relative but keeps rooted; CTRF ignores missing-reportFormat inputs. 70 merge tests pass; full build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 14 ++--- .../TrxReportEngine.Merge.cs | 56 +++++++++++++---- .../CtrfReportMergerTests.cs | 11 +++- .../TrxReportEngineMergeTests.cs | 62 +++++++++++++++++++ 4 files changed, 121 insertions(+), 22 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 7fff9071a1..6a1265b385 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -71,20 +71,18 @@ internal static string Merge(IReadOnlyList inputReports) continue; } - // Only accept genuine CTRF documents: a non-CTRF JSON object (different reportFormat, or no - // results object) must not become 'first' and have CTRF-shaped data emitted under its label. + // Only accept genuine CTRF documents: reportFormat is the required format discriminator, so an + // object without it (or with a non-CTRF value), or with no results object, must not become + // 'first' and have CTRF-shaped data emitted under its label. if (root["results"] is not JsonObject) { continue; } - if (root["reportFormat"] is JsonNode reportFormat) + string? format = root["reportFormat"] is JsonValue formatValue && formatValue.TryGetValue(out string? formatText) ? formatText : null; + if (!string.Equals(format, "CTRF", StringComparison.OrdinalIgnoreCase)) { - string? format = reportFormat is JsonValue formatValue && formatValue.TryGetValue(out string? formatText) ? formatText : null; - if (!string.Equals(format, "CTRF", StringComparison.OrdinalIgnoreCase)) - { - continue; - } + continue; } first ??= root; diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 42135c5fc3..0704155b46 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -60,11 +60,12 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI // id and rewrite that input's testId references, so module-specific definitions are not lost. var definitionsById = new Dictionary(StringComparer.OrdinalIgnoreCase); - // Run-level diagnostics () and collector attachments () live - // under ; carry them across so merged reports don't silently lose crash/exit - // messages and attachment references. + // Run-level diagnostics (), collector attachments () and run-level + // result files (, produced by VSTest) live under ; carry them across so + // merged reports don't silently lose crash/exit messages or attachment references. var mergedRunInfos = new XElement(NamespaceUri + "RunInfos"); var mergedCollectorDataEntries = new XElement(NamespaceUri + "CollectorDataEntries"); + var mergedResultFiles = new XElement(NamespaceUri + "ResultFiles"); // Preserve the order in which counter attributes are first seen so the merged // element keeps the well-known TRX attribute ordering. @@ -121,6 +122,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI AccumulateCounters(FindChild(resultSummary, "Counters"), counterAttributeOrder, counterSums); CloneChildrenInto(FindChild(resultSummary, "RunInfos"), mergedRunInfos); CloneChildrenInto(FindChild(resultSummary, "CollectorDataEntries"), mergedCollectorDataEntries); + CloneChildrenInto(FindChild(resultSummary, "ResultFiles"), mergedResultFiles); } XElement? times = FindChild(testRun, "Times"); @@ -173,7 +175,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI mergedTestRun.Add(mergedTestDefinitions); mergedTestRun.Add(mergedTestEntries); mergedTestRun.Add(mergedTestLists); - mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums, mergedRunInfos, mergedCollectorDataEntries)); + mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums, mergedRunInfos, mergedCollectorDataEntries, mergedResultFiles)); return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedTestRun); } @@ -415,10 +417,11 @@ private static void DropAllReferences(IReadOnlyList reports) } /// - /// Removes every attachment reference (<A> and <ResultFile>) from a report. - /// Used when an input's attachments cannot be relocated (missing/invalid source, or a failed copy), so - /// the merged report never carries a reference that would resolve against the merged deployment root - /// to a file that was never placed there. + /// Removes the relative attachment references (<A> and <ResultFile>) from a + /// report. Used when an input's attachments cannot be relocated (missing/invalid source, or a failed + /// copy), so the merged report never carries a reference that would resolve against the merged + /// deployment root to a file that was never placed there. Rooted (absolute) references are preserved — + /// they resolve independently of the deployment root (RFC 018 keeps absolute paths resolvable). /// private static void DropAllAttachmentReferences(XDocument report) { @@ -429,8 +432,34 @@ private static void DropAllAttachmentReferences(XDocument report) foreach (XElement element in root.Descendants().Where(e => e.Name.LocalName is "A" or "ResultFile").ToList()) { - RemoveReferenceElement(element); + if (!IsRootedReference(element)) + { + RemoveReferenceElement(element); + } + } + } + + /// + /// Returns if an attachment reference resolves to an absolute path (and so is + /// preserved even when relocation is abandoned): a rooted href/path, or a + /// <ResultFile> whose owning UnitTestResult has a rooted + /// relativeResultsDirectory. + /// + private static bool IsRootedReference(XElement element) + { + if (element.Name.LocalName == "A") + { + return element.Attribute("href")?.Value is { } href && Path.IsPathRooted(href); + } + + // ResultFile: rooted if its own path is rooted, or its owning UnitTestResult's directory is rooted. + if (element.Attribute("path")?.Value is { } path && Path.IsPathRooted(path)) + { + return true; } + + XElement? owningResult = element.Ancestors().FirstOrDefault(a => a.Name.LocalName == "UnitTestResult"); + return owningResult?.Attribute("relativeResultsDirectory")?.Value is { } directory && Path.IsPathRooted(directory); } /// @@ -1060,7 +1089,7 @@ private static string GetConfinedDeploymentRootLeaf(string runName) return leaf is "." or ".." ? "_" + leaf : leaf; } - private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement runInfos, XElement collectorDataEntries) + private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement runInfos, XElement collectorDataEntries, XElement resultFiles) { var counters = new XElement(NamespaceUri + "Counters"); foreach (string name in counterAttributeOrder) @@ -1074,7 +1103,7 @@ private static XElement BuildResultSummary(string outcome, List counterA counters); // Emit the diagnostics/attachment children only when they carry content, matching the shape - // the single-run producer writes (which omits empty /). + // the single-run producer writes (which omits empty //). if (runInfos.HasElements) { resultSummary.Add(runInfos); @@ -1085,6 +1114,11 @@ private static XElement BuildResultSummary(string outcome, List counterA resultSummary.Add(collectorDataEntries); } + if (resultFiles.HasElements) + { + resultSummary.Add(resultFiles); + } + return resultSummary; } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index def0ab1313..12875be0dc 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -309,15 +309,20 @@ public void Merge_StampsMergerIdentityInGeneratedBy() public void Merge_IgnoresNonCtrfInputs() { // A JSON object that is not a CTRF document must not be accepted (become 'first') and have - // CTRF-shaped data emitted under its label; its tests are excluded from the merge. + // CTRF-shaped data emitted under its label; its tests are excluded from the merge. This covers both + // a non-CTRF reportFormat and a missing reportFormat (the required format discriminator). string ctrf = BuildReport(testEntries: [Test("a", "passed")]); - var notCtrf = new JsonObject + var wrongFormat = new JsonObject { ["reportFormat"] = "JUnit", ["results"] = new JsonObject { ["tests"] = new JsonArray { Test("x", "passed") } }, }; + var noFormat = new JsonObject + { + ["results"] = new JsonObject { ["tests"] = new JsonArray { Test("y", "passed") } }, + }; - JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([ctrf, notCtrf.ToJsonString()]))!; + JsonNode merged = JsonNode.Parse(CtrfReportMerger.Merge([ctrf, wrongFormat.ToJsonString(), noFormat.ToJsonString()]))!; Assert.AreEqual("CTRF", (string?)merged["reportFormat"]); Assert.AreEqual(1, (long)merged["results"]!["summary"]!["tests"]!); diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 46d00e0976..ac3aa70413 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -156,6 +156,68 @@ string StorageForExecution(string containerName, string executionId) Assert.AreEqual("b.dll", StorageForExecution("TestEntries", "e2")); } + [TestMethod] + public void Merge_CarriesRunLevelResultFiles() + { + // VSTest-produced TRX stores run-level attachments under ResultSummary/ResultFiles; the merge must + // carry them across rather than silently losing them (only RunInfos/CollectorDataEntries were kept). + var resultFiles = new XElement(Ns + "ResultFiles", new XElement(Ns + "ResultFile", new XAttribute("path", "run/summary.txt"))); + XDocument a = BuildReport(resultSummaryChildren: [resultFiles]); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, BuildReport()], Guid.NewGuid(), "run")); + + XElement? mergedResultFiles = summary.Elements().FirstOrDefault(e => e.Name.LocalName == "ResultFiles"); + Assert.IsNotNull(mergedResultFiles); + Assert.HasCount(1, mergedResultFiles.Elements()); + Assert.AreEqual("run/summary.txt", mergedResultFiles.Elements().First().Attribute("path")!.Value); + } + + [TestMethod] + public async Task MergeToFileAsync_WhenSourceMissing_DropsRelativeButPreservesRootedReferences() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // The deployment 'In' root does not exist, so relocation is abandoned for this input. Its + // RELATIVE references must be dropped (they would dangle against the merged root), but a ROOTED + // reference resolves independently of the deployment root and must be preserved (RFC 018). + string inputDir = Path.Combine(tempDirectory, "in"); + Directory.CreateDirectory(inputDir); + string rootedHref = Path.Combine(tempDirectory, "abs", "kept.txt"); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XAttribute("collectorDisplayName", "Code Coverage"), + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", rootedHref))), + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "machine/log.txt")))))); + + XDocument report = BuildReport(resultSummaryChildren: [collectorDataEntries]); + report.Root!.Add(new XElement( + Ns + "TestSettings", + new XAttribute("name", "default"), + new XElement(Ns + "Deployment", new XAttribute("runDeploymentRoot", "dep")))); + + string input = Path.Combine(inputDir, "a.trx"); + report.Save(input); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + + await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + + List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + Assert.AreEqual(rootedHref, hrefs[0]); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + [TestMethod] public void Merge_WhenAnInputHasUnsuccessfulOutcome_MergedOutcomeIsFailed() { From d1b80072415e7919f92cc76245cea8851f333509 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 16 Jul 2026 09:49:06 +0200 Subject: [PATCH 23/26] Merge run-level Output, reject empty input early, filesystem-aware alias check (review) - TRX Merge now carries run-level / across (StdOut/StdErr/ DebugTrace concatenated, TextMessages/Message unioned), emitted in schema order right after , so VSTest's run-level skipped/informational messages are no longer silently dropped. - MergeToFileAsync rejects an empty input list before any filesystem work (output directory creation / attachment relocation), so an invalid call no longer mutates the disk before Merge throws. Applied to TRX/JUnit/CTRF. - Output-alias check is now filesystem-aware: it compares canonicalized paths with a cached case-sensitivity probe (case-sensitive on case-sensitive volumes, insensitive otherwise), so on Linux a legitimate separate output like 'A.trx' is no longer rejected as aliasing input 'a.trx', while Windows/macOS case aliases are still caught. Applied to TRX/JUnit/CTRF. Tests: run-level Output merge + schema order; empty-input rejection without touching the filesystem (all three mergers). 74 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 33 ++++- .../JUnitReportMerger.cs | 33 ++++- .../TrxReportEngine.Merge.cs | 131 +++++++++++++++++- .../CtrfReportMergerTests.cs | 21 +++ .../JUnitReportMergerTests.cs | 21 +++ .../TrxReportEngineMergeTests.cs | 52 +++++++ 6 files changed, 284 insertions(+), 7 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 6a1265b385..128512fbbd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -248,6 +248,13 @@ internal static async Task MergeToFileAsync( throw new ArgumentNullException(nameof(outputPath)); } + // Reject an empty input list before any filesystem work (Merge throws for empty input, but only + // after the output directory would already have been created). + if (inputPaths.Count == 0) + { + throw new ArgumentException("At least one CTRF report is required to merge.", nameof(inputPaths)); + } + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an // output that aliases an input so a merge can never overwrite one of its own sources. EnsureOutputDoesNotAliasInput(inputPaths, outputPath); @@ -459,13 +466,37 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, FileSystemPathComparison)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } + // Whether two file paths name the SAME file depends on the filesystem's case sensitivity: on a + // case-insensitive volume (Windows, default macOS) 'a.json' and 'A.json' are the same file and must + // collide; on a case-sensitive volume (typical Linux) they are DISTINCT files, so a case-insensitive + // comparison would wrongly reject a legitimate separate output. Probe once and cache. + private static readonly StringComparison FileSystemPathComparison = + IsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + private static bool IsFileSystemCaseSensitive() + { + try + { + string upper = Path.Combine(Path.GetTempPath(), "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) + { + return !File.Exists(upper.ToLowerInvariant()); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + // If the probe fails, assume case-insensitive-but-preserving (rejects more, never less). + return false; + } + } + #if !NETCOREAPP private static bool HasReparsePointAncestor(string path) { diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index 452d53c2f7..ce0b1d4d24 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -121,6 +121,13 @@ internal static async Task MergeToFileAsync( throw new ArgumentNullException(nameof(outputPath)); } + // Reject an empty input list before any filesystem work (Merge throws for empty input, but only + // after the output directory would already have been created). + if (inputPaths.Count == 0) + { + throw new ArgumentException("At least one JUnit report is required to merge.", nameof(inputPaths)); + } + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an // output that aliases an input so a merge (which writes with a truncating File.Create) can never // overwrite one of its own sources. @@ -244,13 +251,37 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, FileSystemPathComparison)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } + // Whether two file paths name the SAME file depends on the filesystem's case sensitivity: on a + // case-insensitive volume (Windows, default macOS) 'a.xml' and 'A.xml' are the same file and must + // collide; on a case-sensitive volume (typical Linux) they are DISTINCT files, so a case-insensitive + // comparison would wrongly reject a legitimate separate output. Probe once and cache. + private static readonly StringComparison FileSystemPathComparison = + IsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + private static bool IsFileSystemCaseSensitive() + { + try + { + string upper = Path.Combine(Path.GetTempPath(), "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) + { + return !File.Exists(upper.ToLowerInvariant()); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + // If the probe fails, assume case-insensitive-but-preserving (rejects more, never less). + return false; + } + } + #if !NETCOREAPP private static bool HasReparsePointAncestor(string path) { diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 0704155b46..733bfae472 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -67,6 +67,11 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI var mergedCollectorDataEntries = new XElement(NamespaceUri + "CollectorDataEntries"); var mergedResultFiles = new XElement(NamespaceUri + "ResultFiles"); + // VSTest records run-level skipped/informational messages under / (see + // TrxReportEngine.Metadata.cs). Collect each input's Output element so those messages are merged + // rather than silently dropped. + var resultSummaryOutputs = new List(); + // Preserve the order in which counter attributes are first seen so the merged // element keeps the well-known TRX attribute ordering. var counterAttributeOrder = new List(); @@ -123,6 +128,10 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI CloneChildrenInto(FindChild(resultSummary, "RunInfos"), mergedRunInfos); CloneChildrenInto(FindChild(resultSummary, "CollectorDataEntries"), mergedCollectorDataEntries); CloneChildrenInto(FindChild(resultSummary, "ResultFiles"), mergedResultFiles); + if (FindChild(resultSummary, "Output") is { } output) + { + resultSummaryOutputs.Add(output); + } } XElement? times = FindChild(testRun, "Times"); @@ -175,7 +184,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI mergedTestRun.Add(mergedTestDefinitions); mergedTestRun.Add(mergedTestEntries); mergedTestRun.Add(mergedTestLists); - mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums, mergedRunInfos, mergedCollectorDataEntries, mergedResultFiles)); + mergedTestRun.Add(BuildResultSummary(anyFailure ? "Failed" : "Completed", counterAttributeOrder, counterSums, MergeResultSummaryOutputs(resultSummaryOutputs), mergedRunInfos, mergedCollectorDataEntries, mergedResultFiles)); return new XDocument(new XDeclaration("1.0", "UTF-8", null), mergedTestRun); } @@ -200,6 +209,13 @@ internal static async Task MergeToFileAsync( throw new ArgumentNullException(nameof(outputPath)); } + // Reject an empty input list before any filesystem work: Merge throws for empty input, but only + // after output-directory creation and attachment relocation would already have touched the disk. + if (inputPaths.Count == 0) + { + throw new ArgumentException("At least one TRX report is required to merge.", nameof(inputPaths)); + } + // RFC 018 treats per-module inputs as read-only and requires them to remain on disk. The merged // TRX is written with File.Create (truncating); reject an output that aliases an input so a merge // can never overwrite one of its own sources. @@ -1005,13 +1021,38 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat string outputCanonical = GetCanonicalPath(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, FileSystemPathComparison)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } + // Whether two file paths name the SAME file depends on the filesystem's case sensitivity: on a + // case-insensitive volume (Windows, default macOS) 'a.trx' and 'A.trx' are the same file and must + // collide; on a case-sensitive volume (typical Linux) they are DISTINCT files, so a case-insensitive + // comparison would wrongly reject a legitimate separate output. Probe once and cache. + private static readonly StringComparison FileSystemPathComparison = + IsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + + private static bool IsFileSystemCaseSensitive() + { + try + { + string upper = Path.Combine(Path.GetTempPath(), "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) + { + return !File.Exists(upper.ToLowerInvariant()); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + // If the probe fails, assume case-insensitive-but-preserving (the safer, more conservative + // choice for the alias check: it rejects more, never less). + return false; + } + } + #if !NETCOREAPP private static bool HasReparsePointAncestor(string path) { @@ -1089,7 +1130,7 @@ private static string GetConfinedDeploymentRootLeaf(string runName) return leaf is "." or ".." ? "_" + leaf : leaf; } - private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement runInfos, XElement collectorDataEntries, XElement resultFiles) + private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement? output, XElement runInfos, XElement collectorDataEntries, XElement resultFiles) { var counters = new XElement(NamespaceUri + "Counters"); foreach (string name in counterAttributeOrder) @@ -1102,8 +1143,13 @@ private static XElement BuildResultSummary(string outcome, List counterA new XAttribute("outcome", outcome), counters); - // Emit the diagnostics/attachment children only when they carry content, matching the shape - // the single-run producer writes (which omits empty //). + // Emit the optional children in TRX schema order (Counters, Output, RunInfos, CollectorDataEntries, + // ResultFiles), and only when they carry content, matching the single-run producer's shape. + if (output is not null) + { + resultSummary.Add(output); + } + if (runInfos.HasElements) { resultSummary.Add(runInfos); @@ -1122,6 +1168,81 @@ private static XElement BuildResultSummary(string outcome, List counterA return resultSummary; } + /// + /// Merges the run-level <Output> elements of every input ResultSummary into a single + /// one so run-level std streams and informational/skipped TextMessages survive the merge. The + /// scalar text children (StdOut/StdErr/DebugTrace) are concatenated and the + /// Message entries under TextMessages are unioned. Returns when + /// nothing is present. + /// + private static XElement? MergeResultSummaryOutputs(List outputs) + { + if (outputs.Count == 0) + { + return null; + } + + var stdOut = new StringBuilder(); + var stdErr = new StringBuilder(); + var debugTrace = new StringBuilder(); + var messages = new List(); + + foreach (XElement output in outputs) + { + AppendOutputText(stdOut, FindChild(output, "StdOut")); + AppendOutputText(stdErr, FindChild(output, "StdErr")); + AppendOutputText(debugTrace, FindChild(output, "DebugTrace")); + + if (FindChild(output, "TextMessages") is { } textMessages) + { + foreach (XElement message in textMessages.Elements().Where(e => string.Equals(e.Name.LocalName, "Message", StringComparison.Ordinal))) + { + messages.Add(new XElement(message)); + } + } + } + + var merged = new XElement(NamespaceUri + "Output"); + if (stdOut.Length > 0) + { + merged.Add(new XElement(NamespaceUri + "StdOut", stdOut.ToString())); + } + + if (stdErr.Length > 0) + { + merged.Add(new XElement(NamespaceUri + "StdErr", stdErr.ToString())); + } + + if (debugTrace.Length > 0) + { + merged.Add(new XElement(NamespaceUri + "DebugTrace", debugTrace.ToString())); + } + + if (messages.Count > 0) + { + var textMessages = new XElement(NamespaceUri + "TextMessages"); + textMessages.Add(messages); + merged.Add(textMessages); + } + + return merged.HasElements ? merged : null; + } + + private static void AppendOutputText(StringBuilder builder, XElement? element) + { + if (element is null || RoslynString.IsNullOrEmpty(element.Value)) + { + return; + } + + if (builder.Length > 0) + { + builder.Append('\n'); + } + + builder.Append(element.Value); + } + private static bool TryParseDateTimeOffset(string? value, out DateTimeOffset result) { if (RoslynString.IsNullOrEmpty(value)) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index 12875be0dc..9ab7980bfb 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -18,6 +18,27 @@ public void Merge_WithNullReports_ThrowsArgumentNullException() public void Merge_WithNoReports_ThrowsArgumentException() => Assert.ThrowsExactly(() => CtrfReportMerger.Merge([])); + [TestMethod] + public async Task MergeToFileAsync_WithNoInputs_ThrowsWithoutCreatingOutputDirectory() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"ctrf-merge-{Guid.NewGuid():N}"); + try + { + string output = Path.Combine(tempDirectory, "out", "merged.json"); + await Assert.ThrowsExactlyAsync( + () => CtrfReportMerger.MergeToFileAsync([], output, CancellationToken.None)); + + Assert.IsFalse(Directory.Exists(tempDirectory)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + [TestMethod] public void Merge_ConcatenatesTests() { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs index df4af6db53..9ca3fa8e3c 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/JUnitReportMergerTests.cs @@ -16,6 +16,27 @@ public void Merge_WithNullReports_ThrowsArgumentNullException() public void Merge_WithNoReports_ThrowsArgumentException() => Assert.ThrowsExactly(() => JUnitReportMerger.Merge([], "run")); + [TestMethod] + public async Task MergeToFileAsync_WithNoInputs_ThrowsWithoutCreatingOutputDirectory() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"junit-merge-{Guid.NewGuid():N}"); + try + { + string output = Path.Combine(tempDirectory, "out", "merged.xml"); + await Assert.ThrowsExactlyAsync( + () => JUnitReportMerger.MergeToFileAsync([], output, "run", CancellationToken.None)); + + Assert.IsFalse(Directory.Exists(tempDirectory)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + [TestMethod] public void Merge_SumsRootCounters() { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index ac3aa70413..f7e655abb7 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -18,6 +18,58 @@ public void Merge_WithNullReports_ThrowsArgumentNullException() public void Merge_WithNoReports_ThrowsArgumentException() => Assert.ThrowsExactly(() => TrxReportEngine.Merge([], Guid.NewGuid(), "run")); + [TestMethod] + public async Task MergeToFileAsync_WithNoInputs_ThrowsWithoutTouchingFilesystem() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + try + { + // An empty input list must be rejected before any filesystem work — the output directory must + // not be created for an invalid call. + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + await Assert.ThrowsExactlyAsync( + () => TrxReportEngine.MergeToFileAsync([], output, Guid.NewGuid(), "run", CancellationToken.None)); + + Assert.IsFalse(Directory.Exists(tempDirectory)); + } + finally + { + if (Directory.Exists(tempDirectory)) + { + Directory.Delete(tempDirectory, recursive: true); + } + } + } + + [TestMethod] + public void Merge_CarriesRunLevelOutputMessages() + { + // VSTest records run-level skipped/informational messages under ResultSummary/Output/TextMessages; + // the merge must carry them across (in schema order, right after Counters) rather than dropping them. + var outputA = new XElement( + Ns + "Output", + new XElement(Ns + "StdOut", "hello from a"), + new XElement(Ns + "TextMessages", new XElement(Ns + "Message", "skipped test X"))); + var outputB = new XElement( + Ns + "Output", + new XElement(Ns + "TextMessages", new XElement(Ns + "Message", "informational Y"))); + XDocument a = BuildReport(resultSummaryChildren: [outputA]); + XDocument b = BuildReport(resultSummaryChildren: [outputB]); + + XElement summary = ResultSummary(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run")); + + // Output must appear immediately after Counters (schema order). + List childOrder = [.. summary.Elements().Select(e => e.Name.LocalName)]; + Assert.AreEqual("Counters", childOrder[0]); + Assert.AreEqual("Output", childOrder[1]); + + XElement output = summary.Elements().First(e => e.Name.LocalName == "Output"); + Assert.AreEqual("hello from a", output.Elements().First(e => e.Name.LocalName == "StdOut").Value); + List messages = [.. output.Descendants().Where(e => e.Name.LocalName == "Message").Select(e => e.Value)]; + Assert.Contains("skipped test X", messages); + Assert.Contains("informational Y", messages); + } + [TestMethod] public void Merge_SetsProvidedRunIdAndName() { From f378b5da5b95f3c30ee2ba220256d18323d004f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 16 Jul 2026 10:10:51 +0200 Subject: [PATCH 24/26] Probe output-path case sensitivity at its own directory, not temp (review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The alias-equality check derived its case sensitivity from a one-time probe in Path.GetTempPath(), but case sensitivity can differ per volume and (on Windows) per directory. Replace the cached temp probe with a per-call probe of the OUTPUT's own location (nearest existing ancestor) in all three mergers. An alias can only occur when input and output share a directory, so that shared location's sensitivity is the correct one — a different-directory input never compares equal. Falls back to case-insensitive (rejects more, never less) if the probe fails. This closes the gap where 'A.trx' could alias input 'a.trx' on a case-insensitive output volume while the temp-based probe said Ordinal, letting ReplaceFile delete the input. Applied to TRX/JUnit/CTRF. 74 merge tests pass; full solution build clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 51 +++++++++++++++---- .../JUnitReportMerger.cs | 51 +++++++++++++++---- .../TrxReportEngine.Merge.cs | 51 +++++++++++++++---- 3 files changed, 123 insertions(+), 30 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 128512fbbd..fcc31b5067 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -464,33 +464,64 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat } #endif string outputCanonical = GetCanonicalPath(outputPath); + StringComparison comparison = GetOutputPathComparison(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, FileSystemPathComparison)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } - // Whether two file paths name the SAME file depends on the filesystem's case sensitivity: on a - // case-insensitive volume (Windows, default macOS) 'a.json' and 'A.json' are the same file and must - // collide; on a case-sensitive volume (typical Linux) they are DISTINCT files, so a case-insensitive - // comparison would wrongly reject a legitimate separate output. Probe once and cache. - private static readonly StringComparison FileSystemPathComparison = - IsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ + // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so + // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. + // On a case-insensitive location 'a.json' and 'A.json' are the same file and must collide; on a + // case-sensitive one they are distinct. An alias can only occur when the two paths share a directory, + // so that shared location's sensitivity is the correct one (a different-directory input never compares + // equal). + private static StringComparison GetOutputPathComparison(string outputPath) + { + string? probeDirectory = FindNearestExistingDirectory(outputPath); + return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + } - private static bool IsFileSystemCaseSensitive() + private static string? FindNearestExistingDirectory(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current)) + { + return current; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return current; + } + + private static bool IsDirectoryCaseSensitive(string directory) { try { - string upper = Path.Combine(Path.GetTempPath(), "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) { return !File.Exists(upper.ToLowerInvariant()); } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { // If the probe fails, assume case-insensitive-but-preserving (rejects more, never less). return false; diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index ce0b1d4d24..dc31478911 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -249,33 +249,64 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat } #endif string outputCanonical = GetCanonicalPath(outputPath); + StringComparison comparison = GetOutputPathComparison(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, FileSystemPathComparison)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } - // Whether two file paths name the SAME file depends on the filesystem's case sensitivity: on a - // case-insensitive volume (Windows, default macOS) 'a.xml' and 'A.xml' are the same file and must - // collide; on a case-sensitive volume (typical Linux) they are DISTINCT files, so a case-insensitive - // comparison would wrongly reject a legitimate separate output. Probe once and cache. - private static readonly StringComparison FileSystemPathComparison = - IsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ + // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so + // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. + // On a case-insensitive location 'a.xml' and 'A.xml' are the same file and must collide; on a + // case-sensitive one they are distinct. An alias can only occur when the two paths share a directory, + // so that shared location's sensitivity is the correct one (a different-directory input never compares + // equal). + private static StringComparison GetOutputPathComparison(string outputPath) + { + string? probeDirectory = FindNearestExistingDirectory(outputPath); + return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + } - private static bool IsFileSystemCaseSensitive() + private static string? FindNearestExistingDirectory(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current)) + { + return current; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return current; + } + + private static bool IsDirectoryCaseSensitive(string directory) { try { - string upper = Path.Combine(Path.GetTempPath(), "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) { return !File.Exists(upper.ToLowerInvariant()); } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { // If the probe fails, assume case-insensitive-but-preserving (rejects more, never less). return false; diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 733bfae472..8a4dd53403 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -1019,33 +1019,64 @@ private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPat } #endif string outputCanonical = GetCanonicalPath(outputPath); + StringComparison comparison = GetOutputPathComparison(outputPath); foreach (string inputPath in inputPaths) { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, FileSystemPathComparison)) + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) { throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); } } } - // Whether two file paths name the SAME file depends on the filesystem's case sensitivity: on a - // case-insensitive volume (Windows, default macOS) 'a.trx' and 'A.trx' are the same file and must - // collide; on a case-sensitive volume (typical Linux) they are DISTINCT files, so a case-insensitive - // comparison would wrongly reject a legitimate separate output. Probe once and cache. - private static readonly StringComparison FileSystemPathComparison = - IsFileSystemCaseSensitive() ? StringComparison.Ordinal : StringComparison.OrdinalIgnoreCase; + // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ + // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so + // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. + // On a case-insensitive location 'a.trx' and 'A.trx' are the same file and must collide; on a + // case-sensitive one they are distinct, so a case-insensitive check would wrongly reject a legitimate + // separate output. An alias can only occur when the two paths share a directory, so that shared + // location's sensitivity is the correct one to use (a different-directory input never compares equal). + private static StringComparison GetOutputPathComparison(string outputPath) + { + string? probeDirectory = FindNearestExistingDirectory(outputPath); + return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + } - private static bool IsFileSystemCaseSensitive() + private static string? FindNearestExistingDirectory(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current)) + { + return current; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return current; + } + + private static bool IsDirectoryCaseSensitive(string directory) { try { - string upper = Path.Combine(Path.GetTempPath(), "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) { return !File.Exists(upper.ToLowerInvariant()); } } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) { // If the probe fails, assume case-insensitive-but-preserving (the safer, more conservative // choice for the alias check: it rejects more, never less). From f91fcb29cd7ebbd08cbd59bc8034c08a77c67f4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 16 Jul 2026 10:40:10 +0200 Subject: [PATCH 25/26] Extract shared merge file I/O + alias checks into MergeOutputFileHelper (review) The read-only-input alias check (canonicalization, per-output-directory case-sensitivity probe, netstandard reparse-ancestor fail-closed) and the atomic temporary-sibling write/ replace were duplicated in the TRX, JUnit and CTRF mergers. Because this is security- and data-loss-sensitive, extract it into a single shared SharedExtensionHelpers/ MergeOutputFileHelper.cs (linked into all three, namespace Microsoft.Testing.Extensions), exposing EnsureOutputDoesNotAliasInput and WriteViaTemporarySiblingAsync so the logic can no longer diverge across formats. Each merger now delegates to it; the per-format writer (XDocument stream vs string) is passed as a callback. Registered the shared internal type/ methods in each project's InternalAPI.Unshipped.txt. No behavior change. 74 merge tests pass; full solution build clean (0 warnings). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../CtrfReportMerger.cs | 200 +--------------- .../InternalAPI/InternalAPI.Unshipped.txt | 3 + ...osoft.Testing.Extensions.CtrfReport.csproj | 1 + .../InternalAPI/InternalAPI.Unshipped.txt | 3 + .../JUnitReportMerger.cs | 211 +---------------- ...soft.Testing.Extensions.JUnitReport.csproj | 1 + .../InternalAPI/InternalAPI.Unshipped.txt | 3 + ...rosoft.Testing.Extensions.TrxReport.csproj | 1 + .../TrxReportEngine.Merge.cs | 213 +---------------- .../MergeOutputFileHelper.cs | 220 ++++++++++++++++++ 10 files changed, 255 insertions(+), 601 deletions(-) create mode 100644 src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index fcc31b5067..5c350c9ff4 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -257,7 +257,7 @@ internal static async Task MergeToFileAsync( // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an // output that aliases an input so a merge can never overwrite one of its own sources. - EnsureOutputDoesNotAliasInput(inputPaths, outputPath); + MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(inputPaths, outputPath); var reports = new List(inputPaths.Count); foreach (string inputPath in inputPaths) @@ -278,12 +278,9 @@ internal static async Task MergeToFileAsync( Directory.CreateDirectory(outputDirectory); } - // Write to a temporary sibling, then replace the destination ENTRY. If the output path is a - // symlink/hardlink alias of an input (which the textual alias check above cannot detect because - // Path.GetFullPath does not resolve links), replacing the entry removes only the link and leaves - // the read-only source intact, rather than truncating it in place via WriteAllText. - string tempPath = GetTempSiblingPath(outputPath); - try + // Write to a temporary sibling, then replace the destination ENTRY, so a symlink/hardlink output + // alias of an input has only its link removed rather than the read-only source truncated in place. + await MergeOutputFileHelper.WriteViaTemporarySiblingAsync(outputPath, async tempPath => { #if NETCOREAPP await File.WriteAllTextAsync(tempPath, merged, cancellationToken).ConfigureAwait(false); @@ -291,48 +288,7 @@ internal static async Task MergeToFileAsync( File.WriteAllText(tempPath, merged); await Task.CompletedTask.ConfigureAwait(false); #endif - ReplaceFile(tempPath, outputPath); - } - finally - { - TryDeleteFile(tempPath); - } - } - - private static string GetTempSiblingPath(string outputPath) - { - string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir - ? dir - : Directory.GetCurrentDirectory(); - return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); - } - - private static void ReplaceFile(string tempPath, string outputPath) - { - // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias - // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and - // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link - // removes only the link (never its target's content); an exact (case-insensitive) alias of an - // input has already been rejected, so this cannot delete an input in place. - File.Delete(outputPath); - - File.Move(tempPath, outputPath); - } - - private static void TryDeleteFile(string path) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary - // exception (or the successful result) with a cleanup failure. - } + }).ConfigureAwait(false); } private static bool TryReadLong(JsonNode summary, string propertyName, out long value) @@ -446,152 +402,6 @@ private static string CreateDeterministicReportId(IReadOnlyList inputRep return new Guid(bytes).ToString("D"); } - /// - /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites - /// a source report (RFC 018 keeps inputs on disk, read-only). Paths are canonicalized (symlinks - /// resolved where the runtime supports it) and compared case-insensitively, so a differently-cased - /// path or a symlinked parent directory that aliases an input directory is still detected. - /// - private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) - { -#if !NETCOREAPP - // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that - // aliases an input directory would slip past the textual comparison below and let the write - // replace the input. Fail closed when any existing ancestor of the output is a reparse point. - if (HasReparsePointAncestor(outputPath)) - { - throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); - } -#endif - string outputCanonical = GetCanonicalPath(outputPath); - StringComparison comparison = GetOutputPathComparison(outputPath); - foreach (string inputPath in inputPaths) - { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) - { - throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); - } - } - } - - // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ - // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so - // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. - // On a case-insensitive location 'a.json' and 'A.json' are the same file and must collide; on a - // case-sensitive one they are distinct. An alias can only occur when the two paths share a directory, - // so that shared location's sensitivity is the correct one (a different-directory input never compares - // equal). - private static StringComparison GetOutputPathComparison(string outputPath) - { - string? probeDirectory = FindNearestExistingDirectory(outputPath); - return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - } - - private static string? FindNearestExistingDirectory(string path) - { - string? current = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!RoslynString.IsNullOrEmpty(current)) - { - if (Directory.Exists(current)) - { - return current; - } - - string? parent = Path.GetDirectoryName(current); - if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) - { - break; - } - - current = parent; - } - - return current; - } - - private static bool IsDirectoryCaseSensitive(string directory) - { - try - { - string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); - using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) - { - return !File.Exists(upper.ToLowerInvariant()); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - // If the probe fails, assume case-insensitive-but-preserving (rejects more, never less). - return false; - } - } - -#if !NETCOREAPP - private static bool HasReparsePointAncestor(string path) - { - string? current = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!RoslynString.IsNullOrEmpty(current)) - { - if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) - { - return true; - } - - string? parent = Path.GetDirectoryName(current); - if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) - { - break; - } - - current = parent; - } - - return false; - } -#endif - - /// - /// Canonicalizes to a full path with symlinks/junctions resolved in every - /// existing component (so a symlinked parent directory that aliases another location is detected). On - /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full - /// path. - /// - private static string GetCanonicalPath(string path) - { - string full = Path.GetFullPath(path); -#if NETCOREAPP - try - { - string? root = Path.GetPathRoot(full); - if (RoslynString.IsNullOrEmpty(root)) - { - return full; - } - - string resolved = root; - foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - string next = Path.Combine(resolved, part); - resolved = Directory.Exists(next) - ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : File.Exists(next) - ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : next; - } - - return resolved; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) - { - return full; - } -#else - return full; -#endif - } - private static long Min(long? current, long candidate) => current is null || candidate < current ? candidate : current.Value; diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt index ace8cbe798..47b0d80c90 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/InternalAPI/InternalAPI.Unshipped.txt @@ -6,3 +6,6 @@ static Microsoft.Testing.Extensions.CtrfReport.CtrfReportMerger.MergeToFileAsync static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! +Microsoft.Testing.Extensions.MergeOutputFileHelper +static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath) -> void +static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func! writeToTempAsync) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj index 65a16472f6..a9f7c6dccd 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/Microsoft.Testing.Extensions.CtrfReport.csproj @@ -63,6 +63,7 @@ This package extends Microsoft Testing Platform to produce test reports in the C + diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt index bab5bf29c7..1798ddc8b0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/InternalAPI/InternalAPI.Unshipped.txt @@ -6,3 +6,6 @@ static Microsoft.Testing.Extensions.JUnitReport.JUnitReportMerger.MergeToFileAsy static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipeDirectoryNotWritableErrorMessage.get -> string! static Microsoft.Testing.Platform.Resources.PlatformResources.NamedPipePathTooLongErrorMessage.get -> string! static Microsoft.Testing.Platform.Services.ArtifactNamingHelper.ResolveAndSanitize(string! template, string! processName, string! processId, System.DateTimeOffset timestamp, System.Func! sanitizeLeafFileName) -> string! +Microsoft.Testing.Extensions.MergeOutputFileHelper +static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath) -> void +static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func! writeToTempAsync) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs index dc31478911..9251aaf898 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/JUnitReportMerger.cs @@ -131,7 +131,7 @@ internal static async Task MergeToFileAsync( // RFC 018 treats per-module inputs as read-only and requires them to remain on disk; reject an // output that aliases an input so a merge (which writes with a truncating File.Create) can never // overwrite one of its own sources. - EnsureOutputDoesNotAliasInput(inputPaths, outputPath); + MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(inputPaths, outputPath); var reports = new List(inputPaths.Count); foreach (string inputPath in inputPaths) @@ -148,65 +148,18 @@ internal static async Task MergeToFileAsync( Directory.CreateDirectory(outputDirectory); } - // Write to a temporary sibling, then replace the destination ENTRY. If the output path is a - // symlink/hardlink alias of an input (which the textual alias check above cannot detect because - // Path.GetFullPath does not resolve links), replacing the entry removes only the link and leaves - // the read-only source intact, rather than truncating it in place via File.Create. - string tempPath = GetTempSiblingPath(outputPath); - try + // Write to a temporary sibling, then replace the destination ENTRY, so a symlink/hardlink output + // alias of an input has only its link removed rather than the read-only source truncated in place. + await MergeOutputFileHelper.WriteViaTemporarySiblingAsync(outputPath, async tempPath => { - using (FileStream stream = File.Create(tempPath)) - { + using FileStream stream = File.Create(tempPath); #if NETCOREAPP - await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); + await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); #else - merged.Save(stream, SaveOptions.None); - await Task.CompletedTask.ConfigureAwait(false); + merged.Save(stream, SaveOptions.None); + await Task.CompletedTask.ConfigureAwait(false); #endif - } - - ReplaceFile(tempPath, outputPath); - } - finally - { - TryDeleteFile(tempPath); - } - } - - private static string GetTempSiblingPath(string outputPath) - { - string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir - ? dir - : Directory.GetCurrentDirectory(); - return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); - } - - private static void ReplaceFile(string tempPath, string outputPath) - { - // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias - // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and - // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link - // removes only the link (never its target's content); an exact (case-insensitive) alias of an - // input has already been rejected, so this cannot delete an input in place. - File.Delete(outputPath); - - File.Move(tempPath, outputPath); - } - - private static void TryDeleteFile(string path) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary - // exception (or the successful result) with a cleanup failure. - } + }).ConfigureAwait(false); } private static long ReadLong(XElement element, string attributeName) @@ -230,150 +183,4 @@ private static bool TryReadTimestamp(XElement element, string attributeName, out return DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out result); } - - /// - /// Rejects an output path that resolves to one of the input report paths, so a merge never overwrites - /// a source report (RFC 018 keeps inputs on disk, read-only). Paths are canonicalized (symlinks - /// resolved where the runtime supports it) and compared case-insensitively, so a differently-cased - /// path or a symlinked parent directory that aliases an input directory is still detected. - /// - private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) - { -#if !NETCOREAPP - // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that - // aliases an input directory would slip past the textual comparison below and let the write - // replace the input. Fail closed when any existing ancestor of the output is a reparse point. - if (HasReparsePointAncestor(outputPath)) - { - throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); - } -#endif - string outputCanonical = GetCanonicalPath(outputPath); - StringComparison comparison = GetOutputPathComparison(outputPath); - foreach (string inputPath in inputPaths) - { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) - { - throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); - } - } - } - - // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ - // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so - // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. - // On a case-insensitive location 'a.xml' and 'A.xml' are the same file and must collide; on a - // case-sensitive one they are distinct. An alias can only occur when the two paths share a directory, - // so that shared location's sensitivity is the correct one (a different-directory input never compares - // equal). - private static StringComparison GetOutputPathComparison(string outputPath) - { - string? probeDirectory = FindNearestExistingDirectory(outputPath); - return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - } - - private static string? FindNearestExistingDirectory(string path) - { - string? current = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!RoslynString.IsNullOrEmpty(current)) - { - if (Directory.Exists(current)) - { - return current; - } - - string? parent = Path.GetDirectoryName(current); - if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) - { - break; - } - - current = parent; - } - - return current; - } - - private static bool IsDirectoryCaseSensitive(string directory) - { - try - { - string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); - using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) - { - return !File.Exists(upper.ToLowerInvariant()); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - // If the probe fails, assume case-insensitive-but-preserving (rejects more, never less). - return false; - } - } - -#if !NETCOREAPP - private static bool HasReparsePointAncestor(string path) - { - string? current = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!RoslynString.IsNullOrEmpty(current)) - { - if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) - { - return true; - } - - string? parent = Path.GetDirectoryName(current); - if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) - { - break; - } - - current = parent; - } - - return false; - } -#endif - - /// - /// Canonicalizes to a full path with symlinks/junctions resolved in every - /// existing component (so a symlinked parent directory that aliases another location is detected). On - /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full - /// path. - /// - private static string GetCanonicalPath(string path) - { - string full = Path.GetFullPath(path); -#if NETCOREAPP - try - { - string? root = Path.GetPathRoot(full); - if (RoslynString.IsNullOrEmpty(root)) - { - return full; - } - - string resolved = root; - foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - string next = Path.Combine(resolved, part); - resolved = Directory.Exists(next) - ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : File.Exists(next) - ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : next; - } - - return resolved; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) - { - return full; - } -#else - return full; -#endif - } } diff --git a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj index adb06a621f..58f63bca8c 100644 --- a/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.JUnitReport/Microsoft.Testing.Extensions.JUnitReport.csproj @@ -53,6 +53,7 @@ This package extends Microsoft Testing Platform to produce JUnit XML test report + diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt index 8ef793115a..457e1df4d2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/InternalAPI/InternalAPI.Unshipped.txt @@ -19,3 +19,6 @@ static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteExecutionS static Microsoft.Testing.Platform.IPC.Serializers.BaseSerializer.WriteListPayload(System.IO.Stream! stream, ushort fieldId, T[]? list, System.Action! writeItem) -> void const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Expected = 10 -> ushort const Microsoft.Testing.Platform.IPC.FailedTestResultMessageFieldsId.Actual = 11 -> ushort +Microsoft.Testing.Extensions.MergeOutputFileHelper +static Microsoft.Testing.Extensions.MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(System.Collections.Generic.IReadOnlyList! inputPaths, string! outputPath) -> void +static Microsoft.Testing.Extensions.MergeOutputFileHelper.WriteViaTemporarySiblingAsync(string! outputPath, System.Func! writeToTempAsync) -> System.Threading.Tasks.Task! diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj b/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj index a17ef773ea..f62c76c9c0 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/Microsoft.Testing.Extensions.TrxReport.csproj @@ -34,6 +34,7 @@ + diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 8a4dd53403..278001f7b2 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -219,7 +219,7 @@ internal static async Task MergeToFileAsync( // RFC 018 treats per-module inputs as read-only and requires them to remain on disk. The merged // TRX is written with File.Create (truncating); reject an output that aliases an input so a merge // can never overwrite one of its own sources. - EnsureOutputDoesNotAliasInput(inputPaths, outputPath); + MergeOutputFileHelper.EnsureOutputDoesNotAliasInput(inputPaths, outputPath); var reports = new List(inputPaths.Count); foreach (string inputPath in inputPaths) @@ -243,65 +243,18 @@ internal static async Task MergeToFileAsync( XDocument merged = Merge(reports, runId, runName); - // Write to a temporary sibling, then replace the destination ENTRY. If the output path is a - // symlink/hardlink alias of an input (which the textual alias check above cannot detect because - // Path.GetFullPath does not resolve links), replacing the entry removes only the link and leaves - // the read-only source intact, rather than truncating it in place via File.Create. - string tempPath = GetTempSiblingPath(outputPath); - try + // Write to a temporary sibling, then replace the destination ENTRY, so a symlink/hardlink output + // alias of an input has only its link removed rather than the read-only source truncated in place. + await MergeOutputFileHelper.WriteViaTemporarySiblingAsync(outputPath, async tempPath => { - using (FileStream stream = File.Create(tempPath)) - { + using FileStream stream = File.Create(tempPath); #if NETCOREAPP - await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); + await merged.SaveAsync(stream, SaveOptions.None, cancellationToken).ConfigureAwait(false); #else - merged.Save(stream, SaveOptions.None); - await Task.CompletedTask.ConfigureAwait(false); + merged.Save(stream, SaveOptions.None); + await Task.CompletedTask.ConfigureAwait(false); #endif - } - - ReplaceFile(tempPath, outputPath); - } - finally - { - TryDeleteFile(tempPath); - } - } - - private static string GetTempSiblingPath(string outputPath) - { - string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir - ? dir - : Directory.GetCurrentDirectory(); - return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); - } - - private static void ReplaceFile(string tempPath, string outputPath) - { - // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias - // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and - // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link - // removes only the link (never its target's content); an exact (case-insensitive) alias of an - // input has already been rejected, so this cannot delete an input in place. - File.Delete(outputPath); - - File.Move(tempPath, outputPath); - } - - private static void TryDeleteFile(string path) - { - try - { - if (File.Exists(path)) - { - File.Delete(path); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) - { - // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary - // exception (or the successful result) with a cleanup failure. - } + }).ConfigureAwait(false); } private static XElement? FindChild(XElement parent, string localName) @@ -1000,154 +953,6 @@ private static Guid CreateDeterministicSettingsId(Guid runId) return new Guid(result); } - /// - /// Rejects an output path that resolves to one of the input report paths. RFC 018 treats per-module - /// inputs as read-only and requires them to remain on disk, so a merge must never overwrite a source. - /// Paths are canonicalized (symlinks resolved where the runtime supports it) and compared - /// case-insensitively, so a differently-cased path or a symlinked parent directory that aliases an - /// input directory is still detected. - /// - private static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) - { -#if !NETCOREAPP - // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that - // aliases an input directory would slip past the textual comparison below and let the write - // replace the input. Fail closed when any existing ancestor of the output is a reparse point. - if (HasReparsePointAncestor(outputPath)) - { - throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); - } -#endif - string outputCanonical = GetCanonicalPath(outputPath); - StringComparison comparison = GetOutputPathComparison(outputPath); - foreach (string inputPath in inputPaths) - { - if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) - { - throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); - } - } - } - - // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ - // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so - // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. - // On a case-insensitive location 'a.trx' and 'A.trx' are the same file and must collide; on a - // case-sensitive one they are distinct, so a case-insensitive check would wrongly reject a legitimate - // separate output. An alias can only occur when the two paths share a directory, so that shared - // location's sensitivity is the correct one to use (a different-directory input never compares equal). - private static StringComparison GetOutputPathComparison(string outputPath) - { - string? probeDirectory = FindNearestExistingDirectory(outputPath); - return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) - ? StringComparison.Ordinal - : StringComparison.OrdinalIgnoreCase; - } - - private static string? FindNearestExistingDirectory(string path) - { - string? current = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!RoslynString.IsNullOrEmpty(current)) - { - if (Directory.Exists(current)) - { - return current; - } - - string? parent = Path.GetDirectoryName(current); - if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) - { - break; - } - - current = parent; - } - - return current; - } - - private static bool IsDirectoryCaseSensitive(string directory) - { - try - { - string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); - using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) - { - return !File.Exists(upper.ToLowerInvariant()); - } - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) - { - // If the probe fails, assume case-insensitive-but-preserving (the safer, more conservative - // choice for the alias check: it rejects more, never less). - return false; - } - } - -#if !NETCOREAPP - private static bool HasReparsePointAncestor(string path) - { - string? current = Path.GetDirectoryName(Path.GetFullPath(path)); - while (!RoslynString.IsNullOrEmpty(current)) - { - if (Directory.Exists(current) && IsReparsePoint(current)) - { - return true; - } - - string? parent = Path.GetDirectoryName(current); - if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) - { - break; - } - - current = parent; - } - - return false; - } -#endif - - /// - /// Canonicalizes to a full path with symlinks/junctions resolved in every - /// existing component (so a symlinked parent directory that aliases another location is detected). On - /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full - /// path. - /// - private static string GetCanonicalPath(string path) - { - string full = Path.GetFullPath(path); -#if NETCOREAPP - try - { - string? root = Path.GetPathRoot(full); - if (RoslynString.IsNullOrEmpty(root)) - { - return full; - } - - string resolved = root; - foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - string next = Path.Combine(resolved, part); - resolved = Directory.Exists(next) - ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : File.Exists(next) - ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : next; - } - - return resolved; - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) - { - return full; - } -#else - return full; -#endif - } - /// /// Produces a single, confined deployment-root leaf from for use both in /// the emitted TestSettings/Deployment/@runDeploymentRoot and in attachment relocation, so the diff --git a/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs b/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs new file mode 100644 index 0000000000..848c9d3b65 --- /dev/null +++ b/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Microsoft.Testing.Platform; + +namespace Microsoft.Testing.Extensions; + +/// +/// Shared, security- and data-loss-sensitive filesystem helpers for report mergers (TRX/JUnit/CTRF). +/// Extracted so the read-only-input alias check and the atomic temporary-sibling write cannot diverge +/// across formats: RFC 018 treats per-module inputs as read-only, so a merge must never overwrite one of +/// its own sources. +/// +internal static class MergeOutputFileHelper +{ + /// + /// Rejects an output path that resolves to one of the input report paths. Paths are canonicalized + /// (symlinks resolved where the runtime supports it) and compared using the case sensitivity probed at + /// the output's own location, so a differently-cased or symlinked-parent output that aliases an input + /// is detected while a legitimately distinct output on a case-sensitive volume is not falsely rejected. + /// On runtimes that cannot resolve links, it fails closed when an output ancestor is a reparse point. + /// + internal static void EnsureOutputDoesNotAliasInput(IReadOnlyList inputPaths, string outputPath) + { +#if !NETCOREAPP + // This runtime cannot resolve symlinks/junctions, so a symlinked output parent directory that + // aliases an input directory would slip past the textual comparison below and let the write + // replace the input. Fail closed when any existing ancestor of the output is a reparse point. + if (HasReparsePointAncestor(outputPath)) + { + throw new ArgumentException($"The output path '{outputPath}' has a symbolic-link parent directory that cannot be safely resolved on this runtime; refusing to write to avoid overwriting a read-only input.", nameof(outputPath)); + } +#endif + string outputCanonical = GetCanonicalPath(outputPath); + StringComparison comparison = GetOutputPathComparison(outputPath); + foreach (string inputPath in inputPaths) + { + if (string.Equals(GetCanonicalPath(inputPath), outputCanonical, comparison)) + { + throw new ArgumentException($"The output path '{outputPath}' cannot be one of the input report paths; inputs are treated as read-only.", nameof(outputPath)); + } + } + } + + /// + /// Writes the merged report by invoking against a temporary sibling + /// of and then replacing the destination entry. If the output path is a + /// symlink/hardlink alias of an input (which the textual alias check cannot detect because + /// does not resolve links), replacing the entry removes only the + /// link and leaves the read-only source intact, rather than truncating it in place. + /// + internal static async Task WriteViaTemporarySiblingAsync(string outputPath, Func writeToTempAsync) + { + string tempPath = GetTempSiblingPath(outputPath); + try + { + await writeToTempAsync(tempPath).ConfigureAwait(false); + ReplaceFile(tempPath, outputPath); + } + finally + { + TryDeleteFile(tempPath); + } + } + + /// + /// Canonicalizes to a full path with symlinks/junctions resolved in every + /// existing component (so a symlinked parent directory that aliases another location is detected). On + /// runtimes without link resolution (netstandard/.NET Framework) it falls back to the lexical full + /// path. + /// + private static string GetCanonicalPath(string path) + { + string full = Path.GetFullPath(path); +#if NETCOREAPP + try + { + string? root = Path.GetPathRoot(full); + if (RoslynString.IsNullOrEmpty(root)) + { + return full; + } + + string resolved = root; + foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) + { + string next = Path.Combine(resolved, part); + resolved = Directory.Exists(next) + ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : File.Exists(next) + ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next + : next; + } + + return resolved; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) + { + return full; + } +#else + return full; +#endif + } + + // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ + // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so + // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. + // On a case-insensitive location 'a.trx' and 'A.trx' are the same file and must collide; on a + // case-sensitive one they are distinct, so a case-insensitive check would wrongly reject a legitimate + // separate output. An alias can only occur when the two paths share a directory, so that shared + // location's sensitivity is the correct one (a different-directory input never compares equal). + private static StringComparison GetOutputPathComparison(string outputPath) + { + string? probeDirectory = FindNearestExistingDirectory(outputPath); + return probeDirectory is not null && IsDirectoryCaseSensitive(probeDirectory) + ? StringComparison.Ordinal + : StringComparison.OrdinalIgnoreCase; + } + + private static string? FindNearestExistingDirectory(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current)) + { + return current; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return current; + } + + private static bool IsDirectoryCaseSensitive(string directory) + { + try + { + string upper = Path.Combine(directory, "CASESENSITIVEPROBE" + Guid.NewGuid().ToString("N")); + using (new FileStream(upper, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None, 0x1000, FileOptions.DeleteOnClose)) + { + return !File.Exists(upper.ToLowerInvariant()); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + // If the probe fails, assume case-insensitive-but-preserving (the safer, more conservative + // choice for the alias check: it rejects more, never less). + return false; + } + } + + private static string GetTempSiblingPath(string outputPath) + { + string directory = Path.GetDirectoryName(Path.GetFullPath(outputPath)) is { Length: > 0 } dir + ? dir + : Directory.GetCurrentDirectory(); + return Path.Combine(directory, Path.GetFileName(outputPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + } + + private static void ReplaceFile(string tempPath, string outputPath) + { + // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias + // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and + // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link + // removes only the link (never its target's content); an exact alias of an input has already been + // rejected, so this cannot delete an input in place. + File.Delete(outputPath); + + File.Move(tempPath, outputPath); + } + + private static void TryDeleteFile(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Best-effort temp cleanup: leaking a .tmp sibling is preferable to masking the primary + // exception (or the successful result) with a cleanup failure. + } + } + +#if !NETCOREAPP + private static bool HasReparsePointAncestor(string path) + { + string? current = Path.GetDirectoryName(Path.GetFullPath(path)); + while (!RoslynString.IsNullOrEmpty(current)) + { + if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint) + { + return true; + } + + string? parent = Path.GetDirectoryName(current); + if (parent is null || string.Equals(parent, current, StringComparison.Ordinal)) + { + break; + } + + current = parent; + } + + return false; + } +#endif +} From bd927b563ee3ead454a2db4064c33e4f9fbb193e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amaury=20Lev=C3=A9?= Date: Thu, 16 Jul 2026 14:00:09 +0200 Subject: [PATCH 26/26] Fix macOS symlink-alias canonicalization and address merge review comments CI (macOS): GetCanonicalPath now re-resolves ancestor symlinks after every link hop, so a link target whose prefix is itself a symlink (macOS /var -> /private/var) canonicalizes consistently and the read-only-input alias check no longer misses a symlinked-parent output. Fixes the 3 WhenOutputAliasesInputViaSymlinkedParent merge tests that failed on macOS. Review comments addressed: - MergeOutputFileHelper.ReplaceFile: atomic File.Move(overwrite:true) on netcore so an interruption can never leave the previous report missing; delete-then-move retained only on .NET Framework (no overwrite overload). - TRX relocation: deployment root is now unique per run (run id suffix), so relocation always writes a fresh tree and can never corrupt a previously committed report's attachments if a later merge fails. - TRX Times: earliest creation/queuing/start tracked independently and omitted when no input supplies them, instead of fabricating all three from the earliest start. - CTRF reportId: derived only from accepted CTRF payloads, so an ignored non-CTRF input no longer changes the merged report identity. Adds regression tests for each and updates deployment-root-coupled TRX tests to read the emitted runDeploymentRoot instead of assuming a literal name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34f1a49b-b1e0-4f38-b8a1-3870dd0842d2 --- .../CtrfReportMerger.cs | 22 ++- .../TrxReportEngine.Merge.cs | 65 +++++-- .../MergeOutputFileHelper.cs | 85 +++++--- .../CtrfReportMergerTests.cs | 17 ++ .../TrxReportEngineMergeTests.cs | 182 +++++++++++++++--- 5 files changed, 300 insertions(+), 71 deletions(-) diff --git a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs index 5c350c9ff4..e38a870766 100644 --- a/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs +++ b/src/Platform/Microsoft.Testing.Extensions.CtrfReport/CtrfReportMerger.cs @@ -47,6 +47,11 @@ internal static string Merge(IReadOnlyList inputReports) JsonObject? first = null; var mergedTests = new JsonArray(); + // Accumulate the raw JSON of every ACCEPTED CTRF input so the deterministic reportId is derived only + // from the payloads that actually contributed to the merge. Hashing the unfiltered inputs would let a + // rejected non-CTRF input (which is skipped below) change the merged report's identity. + var acceptedReports = new List(inputReports.Count); + long? earliestStart = null; long? latestStop = null; @@ -87,6 +92,7 @@ internal static string Merge(IReadOnlyList inputReports) first ??= root; reportCount++; + acceptedReports.Add(reportJson); if (root["results"]?["environment"] is JsonObject environment) { @@ -221,7 +227,7 @@ internal static string Merge(IReadOnlyList inputReports) { ["reportFormat"] = first["reportFormat"]?.DeepClone() ?? "CTRF", ["specVersion"] = first["specVersion"]?.DeepClone() ?? "0.0.0", - ["reportId"] = CreateDeterministicReportId(inputReports), + ["reportId"] = CreateDeterministicReportId(acceptedReports), ["timestamp"] = DateTimeOffset.FromUnixTimeMilliseconds(stopMs).ToString("O", CultureInfo.InvariantCulture), // The merged document is produced by this merger, not by any input, so stamp its own identity // rather than carrying the first input's 'generatedBy' (which could report a different producer @@ -371,18 +377,20 @@ private static bool TryReadLong(JsonNode summary, string propertyName, out long } /// - /// Derives a stable reportId from the raw input reports so identical inputs reproduce the same - /// id on every retry (RFC 018 idempotency) without a random source or reusing an input report's id. - /// A non-cryptographic 128-bit FNV-1a fill is sufficient here — the id only needs to be deterministic - /// and collision-resistant enough to identify a merged report, not secret. + /// Derives a stable reportId from the accepted CTRF input reports so identical inputs reproduce + /// the same id on every retry (RFC 018 idempotency) without a random source or reusing an input report's + /// id. Only the payloads that passed CTRF validation are hashed, so a rejected non-CTRF input cannot + /// alter the merged report's identity. A non-cryptographic 128-bit FNV-1a fill is sufficient here — the + /// id only needs to be deterministic and collision-resistant enough to identify a merged report, not + /// secret. /// - private static string CreateDeterministicReportId(IReadOnlyList inputReports) + private static string CreateDeterministicReportId(IReadOnlyList acceptedReports) { const ulong fnvPrime = 1099511628211UL; ulong hashLow = 14695981039346656037UL; ulong hashHigh = 0x9E3779B97F4A7C15UL; - foreach (string report in inputReports) + foreach (string report in acceptedReports) { foreach (char c in report) { diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs index 278001f7b2..8197428ebf 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.cs @@ -22,7 +22,7 @@ internal sealed partial class TrxReportEngine /// /// Results and TestEntries are unioned as-is; TestDefinitions are deduplicated by id (ids are derived deterministically from each test's UID and the schema forbids duplicates). /// TestLists are deduplicated by id (the well-known lists are shared across files). - /// Counters attributes are summed; Times use the earliest start and latest finish. + /// Counters attributes are summed; Times use the earliest creation/queuing/start and latest finish derived from the inputs (attributes no input supplies are omitted). /// RunInfos (crash/exit diagnostics) and CollectorDataEntries (attachment references) are carried across from every input's ResultSummary. /// The result summary outcome is Failed if any input failed, otherwise Completed. /// Attachment hrefs inside CollectorDataEntries are carried as-is; because they are relative to each input's deployment root, the physical attachment files are only relocated to the merged deployment root by (which has the source paths). Callers of the in-memory that need resolvable attachments should relocate them separately. @@ -78,6 +78,8 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI var counterSums = new Dictionary(StringComparer.Ordinal); bool anyFailure = false; + DateTimeOffset? earliestCreation = null; + DateTimeOffset? earliestQueuing = null; DateTimeOffset? earliestStart = null; DateTimeOffset? latestFinish = null; int inputIndex = 0; @@ -137,6 +139,21 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI XElement? times = FindChild(testRun, "Times"); if (times is not null) { + // Each Times attribute is tracked independently from its own inputs rather than fabricated + // from the start: creation and queuing predate execution and cannot be derived from start, + // so any that no input supplies is omitted from the merged report instead of invented. + if (TryParseDateTimeOffset(times.Attribute("creation")?.Value, out DateTimeOffset creation) + && (earliestCreation is null || creation < earliestCreation)) + { + earliestCreation = creation; + } + + if (TryParseDateTimeOffset(times.Attribute("queuing")?.Value, out DateTimeOffset queuing) + && (earliestQueuing is null || queuing < earliestQueuing)) + { + earliestQueuing = queuing; + } + if (TryParseDateTimeOffset(times.Attribute("start")?.Value, out DateTimeOffset start) && (earliestStart is null || start < earliestStart)) { @@ -178,7 +195,7 @@ internal static XDocument Merge(IReadOnlyList inputReports, Guid runI new XAttribute("id", runId), new XAttribute("name", runName)); - mergedTestRun.Add(BuildTimes(earliestStart, latestFinish)); + mergedTestRun.Add(BuildTimes(earliestCreation, earliestQueuing, earliestStart, latestFinish)); mergedTestRun.Add(BuildTestSettings(runId, runName)); mergedTestRun.Add(mergedResults); mergedTestRun.Add(mergedTestDefinitions); @@ -238,8 +255,12 @@ internal static async Task MergeToFileAsync( // "//In/..."). Relocate those trees into the merged report's // deployment root — isolated per input so identical file names from different modules do not // collide — and rewrite the input's hrefs to match, before the merge clones them. + // The merged deployment root is made unique per run (see GetConfinedDeploymentRootLeaf) so this + // relocation always writes into a fresh tree and can never mutate the attachments referenced by a + // previously committed merged TRX at the same output path; a failed serialization therefore leaves + // that prior report and its files consistent, and only orphaned files under the new root remain. // Best-effort and path-confined: failures never fail the merge. - RelocateAttachments(inputPaths, reports, outputDirectory, runName, cancellationToken); + RelocateAttachments(inputPaths, reports, outputDirectory, runId, runName, cancellationToken); XDocument merged = Merge(reports, runId, runName); @@ -271,10 +292,10 @@ await MergeOutputFileHelper.WriteViaTemporarySiblingAsync(outputPath, async temp /// failure is swallowed so it never fails the merge (never-fail-the-run invariant); a reference that /// cannot be relocated is dropped rather than left dangling or pointing outside the deployment root. /// - private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, string runName, CancellationToken cancellationToken) + private static void RelocateAttachments(IReadOnlyList inputPaths, IReadOnlyList reports, string outputDirectory, Guid runId, string runName, CancellationToken cancellationToken) { string outputFull = Path.GetFullPath(outputDirectory); - string mergedDeploymentRoot = GetConfinedDeploymentRootLeaf(runName); + string mergedDeploymentRoot = GetConfinedDeploymentRootLeaf(runId, runName); string mergedRootFull = Path.GetFullPath(Path.Combine(outputFull, mergedDeploymentRoot)); // The confined leaf can no longer be '.' or '..', but keep the defensive lexical/reparse checks: @@ -903,13 +924,21 @@ private static void AccumulateCounters(XElement? counters, List attribut } } - private static XElement BuildTimes(DateTimeOffset? earliestStart, DateTimeOffset? latestFinish) + private static XElement BuildTimes(DateTimeOffset? earliestCreation, DateTimeOffset? earliestQueuing, DateTimeOffset? earliestStart, DateTimeOffset? latestFinish) { var times = new XElement(NamespaceUri + "Times"); + if (earliestCreation is { } creation) + { + times.SetAttributeValue("creation", creation); + } + + if (earliestQueuing is { } queuing) + { + times.SetAttributeValue("queuing", queuing); + } + if (earliestStart is { } start) { - times.SetAttributeValue("creation", start); - times.SetAttributeValue("queuing", start); times.SetAttributeValue("start", start); } @@ -927,7 +956,7 @@ private static XElement BuildTestSettings(Guid runId, string runName) NamespaceUri + "TestSettings", new XAttribute("name", "default"), new XAttribute("id", CreateDeterministicSettingsId(runId))); - testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", GetConfinedDeploymentRootLeaf(runName)))); + testSettings.Add(new XElement(NamespaceUri + "Deployment", new XAttribute("runDeploymentRoot", GetConfinedDeploymentRootLeaf(runId, runName)))); return testSettings; } @@ -954,16 +983,20 @@ private static Guid CreateDeterministicSettingsId(Guid runId) } /// - /// Produces a single, confined deployment-root leaf from for use both in - /// the emitted TestSettings/Deployment/@runDeploymentRoot and in attachment relocation, so the - /// two always agree and neither can escape the output directory. The file-name sanitizer already - /// replaces path separators and reserved names, so the only residual escape values are . and - /// .., which are prefixed to keep the leaf confined. + /// Produces a single, confined, per-run-unique deployment-root leaf from and + /// for use both in the emitted TestSettings/Deployment/@runDeploymentRoot + /// and in attachment relocation, so the two always agree and neither can escape the output directory. The + /// file-name sanitizer already replaces path separators and reserved names, so the only residual escape + /// values are . and .., which are prefixed to keep the leaf confined. The run id (which is + /// deterministic for a given logical run, preserving RFC 018 idempotency) is appended so a merge never + /// reuses the deployment tree of a previously committed report at the same output path — relocation then + /// cannot mutate that prior report's referenced files, and a failed merge leaves it consistent. /// - private static string GetConfinedDeploymentRootLeaf(string runName) + private static string GetConfinedDeploymentRootLeaf(Guid runId, string runName) { string leaf = ReportFileNameSanitizer.ReplaceInvalidFileNameChars(runName); - return leaf is "." or ".." ? "_" + leaf : leaf; + leaf = leaf is "." or ".." ? "_" + leaf : leaf; + return leaf + "." + runId.ToString("N", CultureInfo.InvariantCulture); } private static XElement BuildResultSummary(string outcome, List counterAttributeOrder, Dictionary counterSums, XElement? output, XElement runInfos, XElement collectorDataEntries, XElement resultFiles) diff --git a/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs b/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs index 848c9d3b65..3b52b5f752 100644 --- a/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs +++ b/src/Platform/SharedExtensionHelpers/MergeOutputFileHelper.cs @@ -75,24 +75,7 @@ private static string GetCanonicalPath(string path) #if NETCOREAPP try { - string? root = Path.GetPathRoot(full); - if (RoslynString.IsNullOrEmpty(root)) - { - return full; - } - - string resolved = root; - foreach (string part in full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries)) - { - string next = Path.Combine(resolved, part); - resolved = Directory.Exists(next) - ? new DirectoryInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : File.Exists(next) - ? new FileInfo(next).ResolveLinkTarget(returnFinalTarget: true)?.FullName ?? next - : next; - } - - return resolved; + return ResolveSymlinks(full, remainingHops: 40); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException) { @@ -103,6 +86,51 @@ private static string GetCanonicalPath(string path) #endif } +#if NETCOREAPP + // Resolves every symlink in 'full' to a stable canonical path — including symlinks in the ANCESTOR + // directories of a link's target — so two paths that name the same file compare equal regardless of + // which symlinked route reaches it. A single per-component pass is not enough: when a link's target + // string itself embeds another symlink (e.g. macOS '/var' -> '/private/var', so a link stored as + // '/var/.../real' resolves to a path whose '/var' prefix is still a link), the canonical form would + // otherwise depend on the route taken and the read-only-input alias check would miss an aliased output. + // Each time a link is followed we therefore recurse from the (absolute) target so its own ancestors are + // canonicalized too. 'remainingHops' bounds recursion so a symlink cycle degrades to the lexical path + // instead of looping forever. + private static string ResolveSymlinks(string full, int remainingHops) + { + string? root = Path.GetPathRoot(full); + if (remainingHops <= 0 || RoslynString.IsNullOrEmpty(root)) + { + return full; + } + + string[] parts = full.Substring(root.Length).Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], StringSplitOptions.RemoveEmptyEntries); + string resolved = root; + for (int i = 0; i < parts.Length; i++) + { + string next = Path.Combine(resolved, parts[i]); + FileSystemInfo? entry = Directory.Exists(next) + ? new DirectoryInfo(next) + : File.Exists(next) + ? new FileInfo(next) + : null; + + // Follow a single hop only; recursion re-canonicalizes the target's ancestors and any further + // links in the chain uniformly. + if (entry?.ResolveLinkTarget(returnFinalTarget: false)?.FullName is { } linkTarget) + { + string remainder = string.Join(Path.DirectorySeparatorChar.ToString(), parts, i + 1, parts.Length - (i + 1)); + string combined = remainder.Length == 0 ? linkTarget : Path.Combine(linkTarget, remainder); + return ResolveSymlinks(Path.GetFullPath(combined), remainingHops - 1); + } + + resolved = next; + } + + return resolved; + } +#endif + // Whether two paths name the SAME file depends on the case sensitivity of the specific filesystem/ // directory that will hold the output — which can differ by volume and, on Windows, per-directory — so // this is probed at the output's OWN location (nearest existing ancestor) rather than a fixed temp dir. @@ -168,14 +196,23 @@ private static string GetTempSiblingPath(string outputPath) private static void ReplaceFile(string tempPath, string outputPath) { - // Delete any destination entry unconditionally — a regular file, or a symlink/hardlink alias - // (including a DANGLING symlink, for which File.Exists is false yet the entry still exists and - // would make File.Move fail). File.Delete is a no-op when nothing exists, and deleting a link - // removes only the link (never its target's content); an exact alias of an input has already been - // rejected, so this cannot delete an input in place. +#if NETCOREAPP + // Atomic replace. File.Move(overwrite: true) maps to rename(2) / MoveFileEx(REPLACE_EXISTING), + // which swaps the destination in a single step, so an interruption or competing writer can never + // leave the previous report permanently missing (the failure mode of a delete-then-move sequence). + // It also replaces a symlink or dangling-link ENTRY rather than following it — only the link is + // swapped, never its target's content — and an exact alias of an input has already been rejected, + // so this cannot truncate an input in place. + File.Move(tempPath, outputPath, overwrite: true); +#else + // .NET Framework's File.Move has no atomic-overwrite overload. Fall back to delete-then-move. On + // this runtime the alias check has already fail-closed on any reparse-point ancestor of the output, + // and File.Delete removes only a link entry (never a link target's content), so this cannot delete + // an input in place; deleting a dangling link (for which File.Exists is false yet the entry exists) + // also lets the subsequent File.Move succeed. File.Delete(outputPath); - File.Move(tempPath, outputPath); +#endif } private static void TryDeleteFile(string path) diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs index 9ab7980bfb..b103983f64 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/CtrfReportMergerTests.cs @@ -181,6 +181,23 @@ public void Merge_DerivesDeterministicReportIdNotReusingInput() Assert.AreEqual(mergedId, mergedIdAgain); } + [TestMethod] + public void Merge_ReportIdIsUnaffectedByIgnoredNonCtrfInput() + { + string a = BuildReport(testEntries: [Test("a", "passed")]); + string b = BuildReport(testEntries: [Test("b", "failed")]); + + // A non-CTRF input (missing the reportFormat discriminator) is skipped by the merge, so it must not + // participate in the deterministic reportId — the id must match the CTRF-only merge exactly. + string nonCtrf = "{\"results\":{\"summary\":{},\"tests\":[]}}"; + + string? ctrfOnlyId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, b]))!["reportId"]; + string? withNoiseId = (string?)JsonNode.Parse(CtrfReportMerger.Merge([a, nonCtrf, b]))!["reportId"]; + + Assert.IsNotNull(ctrfOnlyId); + Assert.AreEqual(ctrfOnlyId, withNoiseId); + } + [TestMethod] public async Task MergeToFileAsync_WhenOutputAliasesAnInput_ThrowsArgumentException() { diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index f7e655abb7..1b92d04fec 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -374,6 +374,79 @@ public void Merge_TimesUseEarliestStartAndLatestFinish() Assert.AreEqual(lateFinish, DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); } + [TestMethod] + public void Merge_TimesTrackEarliestCreationQueuingStartIndependently() + { + // Each Times attribute is tracked from its own inputs, not fabricated from the start: the merged + // report keeps the earliest creation, earliest queuing and earliest start even when they come from + // different inputs (creation and queuing legitimately predate execution). + XDocument a = BuildTimesReport(creation: "2020-01-01T08:00:00.0000000+00:00", queuing: "2020-01-01T08:30:00.0000000+00:00", start: "2020-01-01T10:00:00.0000000+00:00", finish: "2020-01-01T11:00:00.0000000+00:00"); + XDocument b = BuildTimesReport(creation: "2020-01-01T09:00:00.0000000+00:00", queuing: "2020-01-01T08:15:00.0000000+00:00", start: "2020-01-01T09:30:00.0000000+00:00", finish: "2020-01-01T12:00:00.0000000+00:00"); + + XElement times = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Times"); + + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T08:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("creation")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T08:15:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("queuing")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T09:30:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("start")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T12:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + } + + [TestMethod] + public void Merge_TimesOmitAttributesNoInputSupplies() + { + // When no input carries creation/queuing, the merged report must omit them rather than invent them + // from the start time. + XDocument a = BuildTimesReport(creation: null, queuing: null, start: "2020-01-01T10:00:00.0000000+00:00", finish: "2020-01-01T11:00:00.0000000+00:00"); + XDocument b = BuildTimesReport(creation: null, queuing: null, start: "2020-01-01T09:00:00.0000000+00:00", finish: "2020-01-01T12:00:00.0000000+00:00"); + + XElement times = Child(TrxReportEngine.Merge([a, b], Guid.NewGuid(), "run").Root!, "Times"); + + Assert.IsNull(times.Attribute("creation")); + Assert.IsNull(times.Attribute("queuing")); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T09:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("start")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + Assert.AreEqual(DateTimeOffset.Parse("2020-01-01T12:00:00+00:00", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind), DateTimeOffset.Parse(times.Attribute("finish")!.Value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind)); + } + + private static XDocument BuildTimesReport(string? creation, string? queuing, string? start, string? finish) + { + var times = new XElement(Ns + "Times"); + if (creation is not null) + { + times.SetAttributeValue("creation", creation); + } + + if (queuing is not null) + { + times.SetAttributeValue("queuing", queuing); + } + + if (start is not null) + { + times.SetAttributeValue("start", start); + } + + if (finish is not null) + { + times.SetAttributeValue("finish", finish); + } + + var testRun = new XElement( + Ns + "TestRun", + new XAttribute("id", Guid.NewGuid()), + new XAttribute("name", "run"), + times, + new XElement(Ns + "Results"), + new XElement(Ns + "TestDefinitions"), + new XElement(Ns + "TestEntries"), + new XElement(Ns + "TestLists", DefaultTestLists()), + new XElement( + Ns + "ResultSummary", + new XAttribute("outcome", "Completed"), + new XElement(Ns + "Counters", new XAttribute("total", 0), new XAttribute("passed", 0)))); + + return new XDocument(testRun); + } + [TestMethod] public void Merge_PreservesResultFileAttachmentPaths() { @@ -443,7 +516,8 @@ public async Task MergeToFileAsync_IsolatesCollidingAttachmentsPerInputAndRewrit await TrxReportEngine.MergeToFileAsync([first, second], output, Guid.NewGuid(), "run", CancellationToken.None); - string mergedInRoot = Path.Combine(tempDirectory, "out", "run", "In"); + string mergedRoot = XDocument.Load(output).Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + string mergedInRoot = Path.Combine(tempDirectory, "out", mergedRoot, "In"); string firstCopied = Path.Combine(mergedInRoot, "0", "machine", "log.txt"); string secondCopied = Path.Combine(mergedInRoot, "1", "machine", "log.txt"); Assert.IsTrue(File.Exists(firstCopied)); @@ -651,7 +725,8 @@ public async Task MergeToFileAsync_RelocatesPerTestResultFilesByPrefixingResultD Assert.AreEqual("machine/log.txt", resultFilePath); // The consumer-resolved path (In//) must point at real bytes. - string resolved = Path.Combine(tempDirectory, "out", "run", "In", relativeDirectory.Replace('/', Path.DirectorySeparatorChar), resultFilePath.Replace('/', Path.DirectorySeparatorChar)); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + string resolved = Path.Combine(tempDirectory, "out", mergedRoot, "In", relativeDirectory.Replace('/', Path.DirectorySeparatorChar), resultFilePath.Replace('/', Path.DirectorySeparatorChar)); Assert.IsTrue(File.Exists(resolved)); Assert.AreEqual("AAA", File.ReadAllText(resolved)); } @@ -859,7 +934,12 @@ public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfine var mergedDoc = XDocument.Load(output); string? deploymentRoot = mergedDoc.Descendants() .FirstOrDefault(e => e.Name.LocalName == "Deployment")?.Attribute("runDeploymentRoot")?.Value; - Assert.AreEqual("_..", deploymentRoot); + Assert.IsNotNull(deploymentRoot); + + // The escape value ".." is confined to a leaf beginning "_.." and carrying no path separator, + // so it can never traverse out of the output directory (the run-id suffix keeps it unique). + Assert.StartsWith("_..", deploymentRoot); + Assert.IsFalse(deploymentRoot.Contains('/') || deploymentRoot.Contains('\\')); Assert.HasCount(1, mergedDoc.Descendants().Where(e => e.Name.LocalName == "A")); // The relocated attachment must live under the OUTPUT directory (confined by the "_.." leaf), @@ -877,24 +957,35 @@ public async Task MergeToFileAsync_WhenRunNameEscapesOutputDirectory_UsesConfine } [TestMethod] - public async Task MergeToFileAsync_WhenMergedRootOverlapsInputTree_SkipsRelocationWithoutRecursing() + public async Task MergeToFileAsync_WhenRunNameMatchesInputDeploymentRoot_UsesUniqueRootAndRelocates() { string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); Directory.CreateDirectory(tempDirectory); try { - // Output written beside the input, with runName matching the input's runDeploymentRoot, so - // the merged 'In' root equals the source 'In' root. Relocation must skip (not recurse into - // its own destination) and leave the already-resolvable href untouched. + // Output written beside the input, with runName matching the input's runDeploymentRoot. Even so, + // the merged deployment root is made unique per run, so it can never coincide with (or recurse + // into) the input's own tree: the attachment is relocated into the unique root, the input's + // original stays untouched, and the rewritten href resolves under the emitted root. string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "run", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "merged.trx"); await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); Assert.IsTrue(File.Exists(output)); + + // The input's original attachment must remain (RFC 018 inputs are read-only). Assert.IsTrue(File.Exists(Path.Combine(tempDirectory, "run", "In", "machine", "log.txt"))); - List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; - Assert.Contains("machine/log.txt", hrefs); + + var mergedDoc = XDocument.Load(output); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + Assert.AreNotEqual("run", mergedRoot); + + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + Assert.HasCount(1, hrefs); + string resolved = Path.Combine(tempDirectory, mergedRoot, "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(resolved)); + Assert.AreEqual("AAA", File.ReadAllText(resolved)); } finally { @@ -909,9 +1000,9 @@ public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndK Directory.CreateDirectory(tempDirectory); try { - // Output written into a subfolder whose deployment root ('run') sits ABOVE the input's own - // deployment tree, so the source 'In' root is strictly nested under the merged 'In' root. - // Relocation must stage the copy so the merged TRX's rewritten href points at real bytes. + // Output written beside a deeply-nested input. The merged deployment root is unique, so the + // rewritten href resolves to the relocated copy under the emitted root (read from the report, + // never assumed to be the plain runName). string inputDir = Path.Combine(tempDirectory, "run", "In", "child"); string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "merged.trx"); @@ -919,10 +1010,12 @@ public async Task MergeToFileAsync_WhenSourceNestedUnderMergedRoot_RelocatesAndK await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); Assert.IsTrue(File.Exists(output)); - List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + var mergedDoc = XDocument.Load(output); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; Assert.HasCount(1, hrefs); // The rewritten href must resolve to a real file under the merged deployment root. - string resolved = Path.Combine(tempDirectory, "run", "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + string resolved = Path.Combine(tempDirectory, mergedRoot, "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); Assert.IsTrue(File.Exists(resolved)); Assert.AreEqual("AAA", File.ReadAllText(resolved)); } @@ -939,16 +1032,18 @@ public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_IsBoundedAndNonD Directory.CreateDirectory(tempDirectory); try { - // Layout where the merged deployment root is nested strictly INSIDE the input's source 'In' - // tree. Because only referenced attachment files are copied (never the whole tree), repeated - // merges into the same output neither recurse into their own destination nor accumulate - // deeper copies: the original stays untouched and there is exactly one relocated copy. + // Layout where the input attachment lives under a 'dep' tree and the merged output is written + // deep inside it. Repeated merges of the SAME logical run (a stable run id, hence a stable + // unique deployment root) must stay bounded and non-destructive: only referenced files are + // copied (never the whole tree), so the original stays untouched and there is exactly one + // relocated copy no matter how many times the merge runs. string input = WriteReportWithAttachment(tempDirectory, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "dep", "In", "out", "merged.trx"); + var runId = Guid.NewGuid(); for (int run = 0; run < 4; run++) { - await TrxReportEngine.MergeToFileAsync([input], output, Guid.NewGuid(), "run", CancellationToken.None); + await TrxReportEngine.MergeToFileAsync([input], output, runId, "run", CancellationToken.None); } Assert.IsTrue(File.Exists(output)); @@ -964,10 +1059,12 @@ public async Task MergeToFileAsync_RepeatedIntoSameNestedLayout_IsBoundedAndNonD Assert.HasCount(2, copies); // The merged report's rewritten href must resolve to the relocated copy under the merged - // deployment root ('/run/In'). - List hrefs = [.. XDocument.Load(output).Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; + // deployment root (read from the report; unique per run). + var mergedDoc = XDocument.Load(output); + string mergedRoot = mergedDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + List hrefs = [.. mergedDoc.Descendants().Where(e => e.Name.LocalName == "A").Select(e => e.Attribute("href")!.Value)]; Assert.HasCount(1, hrefs); - string resolved = Path.Combine(tempDirectory, "dep", "In", "out", "run", "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); + string resolved = Path.Combine(tempDirectory, "dep", "In", "out", mergedRoot, "In", hrefs[0].Replace('/', Path.DirectorySeparatorChar)); Assert.IsTrue(File.Exists(resolved)); Assert.AreEqual("AAA", File.ReadAllText(resolved)); } @@ -984,8 +1081,9 @@ public async Task MergeToFileAsync_WhenInputLivesUnderMergedRoot_PreservesOrigin Directory.CreateDirectory(tempDirectory); try { - // The input report and its attachment live under the merged 'In' root (output beside them, - // runName 'run'). Relocation must never delete the originals (RFC 018 requires they remain). + // The input report and its attachment live under a 'run/In' tree while the output is written + // beside them with runName 'run'. Relocation must never delete the originals (RFC 018 requires + // they remain), and the unique merged root keeps relocation clear of the input's own tree. string inputDir = Path.Combine(tempDirectory, "run", "In", "0"); string input = WriteReportWithAttachment(inputDir, "a.trx", deploymentRoot: "dep", attachmentContent: "AAA"); string output = Path.Combine(tempDirectory, "merged.trx"); @@ -1003,6 +1101,42 @@ public async Task MergeToFileAsync_WhenInputLivesUnderMergedRoot_PreservesOrigin } } + [TestMethod] + public async Task MergeToFileAsync_SecondMergeIntoSameOutput_DoesNotCorruptPriorReportsAttachments() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + // A first merge commits a report plus its relocated attachment tree. A second, DIFFERENT merge + // (distinct run id) into the same output path must write into its own unique deployment root and + // leave the first report's referenced files byte-for-byte intact — so a failure of the second + // merge could never corrupt an already-committed report (RFC 018 data-integrity). + string firstInput = WriteReportWithAttachment(Path.Combine(tempDirectory, "in1"), "a.trx", deploymentRoot: "dep", attachmentContent: "FIRST"); + string output = Path.Combine(tempDirectory, "out", "merged.trx"); + await TrxReportEngine.MergeToFileAsync([firstInput], output, Guid.NewGuid(), "run", CancellationToken.None); + + var firstDoc = XDocument.Load(output); + string firstRoot = firstDoc.Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + string firstHref = firstDoc.Descendants().First(e => e.Name.LocalName == "A").Attribute("href")!.Value; + string firstCopy = Path.Combine(tempDirectory, "out", firstRoot, "In", firstHref.Replace('/', Path.DirectorySeparatorChar)); + Assert.IsTrue(File.Exists(firstCopy)); + + string secondInput = WriteReportWithAttachment(Path.Combine(tempDirectory, "in2"), "b.trx", deploymentRoot: "dep", attachmentContent: "SECOND"); + await TrxReportEngine.MergeToFileAsync([secondInput], output, Guid.NewGuid(), "run", CancellationToken.None); + + // The second merge used a distinct unique root, so the first report's attachment is untouched. + string secondRoot = XDocument.Load(output).Descendants().First(e => e.Name.LocalName == "Deployment").Attribute("runDeploymentRoot")!.Value; + Assert.AreNotEqual(firstRoot, secondRoot); + Assert.IsTrue(File.Exists(firstCopy)); + Assert.AreEqual("FIRST", File.ReadAllText(firstCopy)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + private static string WriteReportWithAttachment(string inputDirectory, string fileName, string deploymentRoot, string attachmentContent) { Directory.CreateDirectory(inputDirectory);