diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.Attachments.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.Attachments.cs index 76c85637dc..f7b31dddfb 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.Attachments.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.Attachments.cs @@ -19,6 +19,23 @@ internal sealed partial class TrxReportEngine /// 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, Guid runId, string runName, CancellationToken cancellationToken) + => RelocateAttachments( + inputPaths, + reports, + outputDirectory, + runId, + runName, + cancellationToken, + static path => File.GetAttributes(path)); + + private static void RelocateAttachments( + IReadOnlyList inputPaths, + IReadOnlyList reports, + string outputDirectory, + Guid runId, + string runName, + CancellationToken cancellationToken, + Func getAttributes) { string outputFull = Path.GetFullPath(outputDirectory); string mergedDeploymentRoot = GetConfinedDeploymentRootLeaf(runId, runName); @@ -40,7 +57,17 @@ private static void RelocateAttachments(IReadOnlyList inputPaths, IReadO // 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)) + bool? mergedInRootIsReparsePoint = GetReparsePointStatus( + mergedInRoot, + getAttributes); + if (mergedInRootIsReparsePoint is null) + { + // Do not delete an entry whose type cannot be established, and do not write through it. + DropAllReferences(reports); + return; + } + + if (mergedInRootIsReparsePoint is true) { try { diff --git a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.PathHelpers.cs b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.PathHelpers.cs index 663905f68e..f75a418f2a 100644 --- a/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.PathHelpers.cs +++ b/src/Platform/Microsoft.Testing.Extensions.TrxReport/TrxReportEngine.Merge.PathHelpers.cs @@ -1,6 +1,8 @@ // 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.Security; + using Microsoft.Testing.Platform; namespace Microsoft.Testing.Extensions.TrxReport.Abstractions; @@ -55,20 +57,53 @@ private static bool IsReparsePoint(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; /// - /// Returns if any existing directory component strictly below + /// Returns whether is a reparse point, or when its + /// attributes cannot be read. A missing entry returns because callers may + /// safely create missing output components after inspecting their existing ancestors. + /// + private static bool? GetReparsePointStatus(string path, Func getAttributes) + { + try + { + return (getAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + // Missing output components are safe: callers create them after validating existing ancestors. + return false; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or SecurityException) + { + // If an existing entry cannot be inspected, it cannot be proven confined. + return null; + } + } + + /// + /// Returns if any existing file-system 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. + /// reparse point (symlink/junction), or cannot be inspected. 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) + => HasReparsePointComponent( + candidateFullPath, + baseDirectory, + static path => File.GetAttributes(path)); + + private static bool HasReparsePointComponent( + string candidateFullPath, + string baseDirectory, + Func getAttributes) { string baseFull = Path.GetFullPath(baseDirectory); string current = candidateFullPath; while (!string.Equals(current, baseFull, PathComparison) && IsUnderDirectory(current, baseFull)) { - if (Directory.Exists(current) && IsReparsePoint(current)) + if (GetReparsePointStatus(current, getAttributes) is not false) { return true; } diff --git a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs index 1b92d04fec..345505c900 100644 --- a/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs +++ b/test/UnitTests/Microsoft.Testing.Extensions.UnitTests/TrxReportEngineMergeTests.cs @@ -1,6 +1,10 @@ // 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 System.Runtime.ExceptionServices; +using System.Security; + using Microsoft.Testing.Extensions.TrxReport.Abstractions; namespace Microsoft.Testing.Extensions.UnitTests; @@ -9,6 +13,16 @@ namespace Microsoft.Testing.Extensions.UnitTests; public sealed class TrxReportEngineMergeTests { private static readonly XNamespace Ns = "http://microsoft.com/schemas/VisualStudio/TeamTest/2010"; + private static readonly MethodInfo GetReparsePointStatusMethod = typeof(TrxReportEngine) + .GetMethod("GetReparsePointStatus", BindingFlags.NonPublic | BindingFlags.Static)!; + + private static readonly MethodInfo HasReparsePointComponentMethod = typeof(TrxReportEngine) + .GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => method.Name == "HasReparsePointComponent" && method.GetParameters().Length == 3); + + private static readonly MethodInfo RelocateAttachmentsMethod = typeof(TrxReportEngine) + .GetMethods(BindingFlags.NonPublic | BindingFlags.Static) + .Single(method => method.Name == "RelocateAttachments" && method.GetParameters().Length == 7); [TestMethod] public void Merge_WithNullReports_ThrowsArgumentNullException() @@ -41,6 +55,217 @@ await Assert.ThrowsExactlyAsync( } } + [TestMethod] + public void GetReparsePointStatus_ReadsAttributesOnceAndDetectsReparsePoint() + { + int callCount = 0; + + bool? result = GetReparsePointStatus( + "link", + path => + { + Assert.AreEqual("link", path); + callCount++; + return FileAttributes.ReparsePoint; + }); + + Assert.IsTrue(result); + Assert.AreEqual(1, callCount); + } + + [TestMethod] + public void GetReparsePointStatus_WhenEntryIsARegularFile_ReturnsFalse() + { + bool? result = GetReparsePointStatus( + "file", + _ => FileAttributes.Archive); + + Assert.IsFalse(result); + } + + [TestMethod] + public void GetReparsePointStatus_WhenEntryIsMissing_ReturnsFalse() + { + Assert.IsFalse(GetReparsePointStatus( + "missing-file", + _ => throw new FileNotFoundException())); + Assert.IsFalse(GetReparsePointStatus( + "missing-directory", + _ => throw new DirectoryNotFoundException())); + } + + [TestMethod] + public void GetReparsePointStatus_WhenAttributesCannotBeRead_ReturnsNull() + { + Assert.IsNull(GetReparsePointStatus( + "inaccessible", + _ => throw new UnauthorizedAccessException())); + Assert.IsNull(GetReparsePointStatus( + "io-failure", + _ => throw new IOException())); + Assert.IsNull(GetReparsePointStatus( + "security-failure", + _ => throw new SecurityException())); + } + + [TestMethod] + public void GetReparsePointStatus_WhenUnexpectedExceptionOccurs_Propagates() + => Assert.ThrowsExactly( + () => GetReparsePointStatus( + "invalid", + _ => throw new InvalidOperationException())); + + [TestMethod] + public void HasReparsePointComponent_WhenAttributesCannotBeRead_RejectsComponent() + { + string baseDirectory = Path.GetFullPath(Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}")); + string candidate = Path.Combine(baseDirectory, "child"); + int callCount = 0; + + bool result = HasReparsePointComponent( + candidate, + baseDirectory, + _ => + { + callCount++; + throw new UnauthorizedAccessException(); + }); + + Assert.IsTrue(result); + Assert.AreEqual(1, callCount); + } + + [TestMethod] + public void RelocateAttachments_WhenMergedInRootAttributesCannotBeRead_DropsReferencesWithoutDeletingRoot() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + var runId = Guid.NewGuid(); + const string runName = "run"; + string outputDirectory = Path.Combine(tempDirectory, "out"); + string mergedInRoot = Path.Combine(outputDirectory, $"{runName}.{runId:N}", "In"); + Directory.CreateDirectory(mergedInRoot); + string markerFile = Path.Combine(mergedInRoot, "marker.txt"); + File.WriteAllText(markerFile, "keep"); + + string inputDirectory = Path.Combine(tempDirectory, "input"); + string sourceInRoot = Path.Combine(inputDirectory, "dep", "In"); + Directory.CreateDirectory(sourceInRoot); + File.WriteAllText(Path.Combine(sourceInRoot, "relative.txt"), "attachment"); + string inputPath = Path.Combine(inputDirectory, "input.trx"); + + var collectorDataEntries = new XElement( + Ns + "CollectorDataEntries", + new XElement( + Ns + "Collector", + new XElement( + Ns + "UriAttachments", + new XElement(Ns + "UriAttachment", new XElement(Ns + "A", new XAttribute("href", "relative.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")))); + + RelocateAttachments( + [inputPath], + [report], + outputDirectory, + runId, + runName, + CancellationToken.None, + path => path == mergedInRoot + ? throw new UnauthorizedAccessException() + : File.GetAttributes(path)); + + Assert.IsEmpty(report.Descendants().Where(element => element.Name.LocalName == "A")); + Assert.IsTrue(Directory.Exists(mergedInRoot)); + Assert.IsTrue(File.Exists(markerFile)); + Assert.IsEmpty(Directory.GetDirectories(mergedInRoot)); + Assert.HasCount(1, Directory.GetFiles(mergedInRoot)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } + +#if NETCOREAPP + [TestMethod] + public void GetReparsePointStatus_WhenDirectoryLinkIsDangling_ReturnsTrue() + { + string tempDirectory = Path.Combine(Path.GetTempPath(), $"trx-merge-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDirectory); + try + { + string link = Path.Combine(tempDirectory, "link"); + string missingTarget = Path.Combine(tempDirectory, "missing"); + try + { + Directory.CreateSymbolicLink(link, missingTarget); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + Assert.Inconclusive("Host cannot create directory symbolic links."); + return; + } + + Assert.IsTrue(GetReparsePointStatus(link, static path => File.GetAttributes(path))); + Assert.IsFalse(Directory.Exists(missingTarget)); + } + finally + { + Directory.Delete(tempDirectory, recursive: true); + } + } +#endif + + private static bool? GetReparsePointStatus(string path, Func getAttributes) + => (bool?)InvokePrivate(GetReparsePointStatusMethod, path, getAttributes); + + private static bool HasReparsePointComponent( + string candidateFullPath, + string baseDirectory, + Func getAttributes) + => (bool)InvokePrivate( + HasReparsePointComponentMethod, + candidateFullPath, + baseDirectory, + getAttributes)!; + + private static void RelocateAttachments( + IReadOnlyList inputPaths, + IReadOnlyList reports, + string outputDirectory, + Guid runId, + string runName, + CancellationToken cancellationToken, + Func getAttributes) + => InvokePrivate( + RelocateAttachmentsMethod, + inputPaths, + reports, + outputDirectory, + runId, + runName, + cancellationToken, + getAttributes); + + private static object? InvokePrivate(MethodInfo method, params object?[] arguments) + { + try + { + return method.Invoke(null, arguments); + } + catch (TargetInvocationException ex) when (ex.InnerException is not null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + } + [TestMethod] public void Merge_CarriesRunLevelOutputMessages() {