Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Testing.Extensions.JUnitReport.Resources;
using Microsoft.Testing.Platform;
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Extensions;
using Microsoft.Testing.Platform.Extensions.CommandLine;
Expand All @@ -14,8 +13,6 @@ internal sealed class JUnitReportGeneratorCommandLine : CommandLineOptionsProvid
public const string JUnitReportOptionName = "report-junit";
public const string JUnitReportFileNameOptionName = "report-junit-filename";

private static readonly char[] DirectorySeparators = [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar];

public JUnitReportGeneratorCommandLine()
: base(
nameof(JUnitReportGeneratorCommandLine),
Expand All @@ -30,101 +27,21 @@ public JUnitReportGeneratorCommandLine()
}

public override Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption commandOption, string[] arguments)
{
if (commandOption.Name == JUnitReportFileNameOptionName)
{
if (arguments.Length is 0)
{
return ValidationResult.InvalidTask(ExtensionResources.JUnitReportFileNameMustNotBeEmpty);
}

string argument = arguments[0];

string fileNamePart = Path.GetFileName(argument);
if (RoslynString.IsNullOrWhiteSpace(fileNamePart))
{
return ValidationResult.InvalidTask(ExtensionResources.JUnitReportFileNameMustNotBeEmpty);
}

if (!fileNamePart.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
{
return ValidationResult.InvalidTask(ExtensionResources.JUnitReportFileNameExtensionIsNotXml);
}

if (EscapesResultsDirectory(argument))
{
return ValidationResult.InvalidTask(ExtensionResources.JUnitReportFileNameRelativePathMustStayUnderResultsDirectory);
}
}

return ValidationResult.ValidTask;
}
=> commandOption.Name == JUnitReportFileNameOptionName
? global::Microsoft.Testing.Extensions.ReportFileNameValidator.ValidateReportFileNameArgumentAsync(
arguments,
".xml",
ExtensionResources.JUnitReportFileNameMustNotBeEmpty,
ExtensionResources.JUnitReportFileNameExtensionIsNotXml,
ExtensionResources.JUnitReportFileNameRelativePathMustStayUnderResultsDirectory)
: ValidationResult.ValidTask;

public override Task<ValidationResult> ValidateCommandLineOptionsAsync(ICommandLineOptions commandLineOptions)
=> commandLineOptions.IsOptionSet(JUnitReportFileNameOptionName) && !commandLineOptions.IsOptionSet(JUnitReportOptionName)
? ValidationResult.InvalidTask(ExtensionResources.JUnitReportFileNameRequiresJUnitReport)
: commandLineOptions.IsOptionSet(JUnitReportOptionName) && commandLineOptions.IsOptionSet(PlatformCommandLineProvider.DiscoverTestsOptionKey)
? ValidationResult.InvalidTask(ExtensionResources.JUnitReportIsNotValidForDiscovery)
: ValidationResult.ValidTask;

private static bool EscapesResultsDirectory(string path)
{
// Fully-qualified paths (e.g. "C:\foo.xml", "\\server\share\foo.xml" or "/foo.xml") are
// accepted as-is and validated by the OS when we open the file - the user explicitly opted
// out of writing under the test results directory.
if (IsPathFullyQualified(path))
{
return false;
}

// Drive-relative paths on Windows such as "C:foo.xml" are "rooted" but not fully qualified -
// they resolve against the current directory of the drive, which is unpredictable and would
// silently escape the test results directory. Reject them. On non-Windows OSes
// Path.IsPathRooted only returns true for paths starting with "/", which are already handled
// above, so this check is effectively Windows-only and matches the TRX option behavior.
if (Path.IsPathRooted(path))
{
return true;
}

// Any remaining ".." segment in a relative path would escape the test results directory.
return path.Split(DirectorySeparators, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == "..");
}

private static bool IsPathFullyQualified(string path)
{
#if NETCOREAPP
return Path.IsPathFullyQualified(path);
#else
// Mirrors the runtime implementation that is missing on .NET Framework and netstandard2.0.
if (path.Length < 2)
{
return false;
}

// UNC paths like "\\server\share" (or with forward slashes).
if (IsDirectorySeparator(path[0]) && IsDirectorySeparator(path[1]))
{
return true;
}

// On Unix, only paths starting with "/" are fully qualified.
if (Path.DirectorySeparatorChar == '/')
{
return path[0] == '/';
}

// On Windows, fully qualified drive paths must be "X:\" or "X:/".
return path.Length >= 3
&& IsValidDriveLetter(path[0])
&& path[1] == ':'
&& IsDirectorySeparator(path[2]);

static bool IsDirectorySeparator(char c)
=> c == Path.DirectorySeparatorChar || c == Path.AltDirectorySeparatorChar;

static bool IsValidDriveLetter(char c)
=> c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z');
#endif
}
=> global::Microsoft.Testing.Extensions.ReportFileNameValidator.ValidateReportCommandLineOptionsAsync(
commandLineOptions,
JUnitReportOptionName,
JUnitReportFileNameOptionName,
ExtensionResources.JUnitReportFileNameRequiresJUnitReport,
ExtensionResources.JUnitReportIsNotValidForDiscovery,
PlatformCommandLineProvider.DiscoverTestsOptionKey);
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ This package extends Microsoft Testing Platform to produce JUnit XML test report
<Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Helpers\RoslynString.cs" Link="Helpers\RoslynString.cs" />
<Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Helpers\ApplicationStateGuard.cs" Link="Helpers\ApplicationStateGuard.cs" />
<Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Helpers\ExitCodes.cs" Link="Helpers\ExitCodes.cs" />
<Compile Include="$(RepoRoot)src\Platform\SharedExtensionHelpers\ReportFileNameValidator.cs" Link="Helpers\ReportFileNameValidator.cs" />
<Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Services\ArtifactNamingHelper.cs" Link="Services\ArtifactNamingHelper.cs" />
<Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\OutputDevice\TargetFrameworkParser.cs" Link="Helpers\TargetFrameworkParser.cs" />
<Compile Include="$(RepoRoot)src\Platform\Microsoft.Testing.Platform\Resources\PlatformResources.cs" Link="Resources\PlatformResources.cs" />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
// 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;
using Microsoft.Testing.Extensions.UnitTests.Helpers;
using Microsoft.Testing.Platform.CommandLine;

namespace Microsoft.Testing.Extensions.UnitTests;

[TestClass]
public sealed class JUnitReportGeneratorCommandLineTests
{
[TestMethod]
[DataRow("report.xml")]
[DataRow("sub/report.xml")]
public async Task IsValid_If_JUnitFileNameOrNestedRelativePath_Is_Provided(string fileName)
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false);

Assert.IsTrue(result.IsValid);
Assert.IsTrue(string.IsNullOrEmpty(result.ErrorMessage));
}

[TestMethod]
[OSCondition(OperatingSystems.Windows)]
public async Task IsValid_If_JUnitFileNameUsesBackslashSeparator_OnWindows()
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, ["sub\\report.xml"]).ConfigureAwait(false);

Assert.IsTrue(result.IsValid);
Assert.IsTrue(string.IsNullOrEmpty(result.ErrorMessage));
}

[TestMethod]
public async Task IsValid_If_JUnitFile_Has_Absolute_Path()
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);
string fileName = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName() + ".xml");

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false);

Assert.IsTrue(result.IsValid);
Assert.IsTrue(string.IsNullOrEmpty(result.ErrorMessage));
}

[TestMethod]
[DataRow("report.txt")]
[DataRow("report")]
public async Task IsInvalid_If_FileName_Does_Not_End_With_Xml(string fileName)
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportFileNameExtensionIsNotXml, result.ErrorMessage);
}

[TestMethod]
[DataRow("../report.xml")]
[DataRow("nested/../report.xml")]
public async Task IsInvalid_If_RelativePath_Contains_ParentDirectorySegment(string fileName)
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportFileNameRelativePathMustStayUnderResultsDirectory, result.ErrorMessage);
}

[TestMethod]
[OSCondition(OperatingSystems.Windows)]
public async Task IsInvalid_If_JUnitFile_Uses_DriveRelativePath_OnWindows()
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, ["C:report.xml"]).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportFileNameRelativePathMustStayUnderResultsDirectory, result.ErrorMessage);
}

[TestMethod]
[DataRow(" ")]
[DataRow("sub/")]
[DataRow("sub/ ")]
public async Task IsInvalid_If_FileNamePart_Is_Empty_Or_Whitespace(string fileName)
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, [fileName]).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportFileNameMustNotBeEmpty, result.ErrorMessage);
}

[TestMethod]
public async Task IsInvalid_If_No_Argument_Provided()
{
var provider = new JUnitReportGeneratorCommandLine();
Platform.Extensions.CommandLine.CommandLineOption option = provider.GetCommandLineOptions()
.First(x => x.Name == JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName);

ValidationResult result = await provider.ValidateOptionArgumentsAsync(option, []).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportFileNameMustNotBeEmpty, result.ErrorMessage);
}

[TestMethod]
public async Task IsInvalid_If_FileName_Provided_Without_JUnitReport_Flag()
{
var provider = new JUnitReportGeneratorCommandLine();
var options = new Dictionary<string, string[]>
{
[JUnitReportGeneratorCommandLine.JUnitReportFileNameOptionName] = ["report.xml"],
};

ValidationResult result = await provider.ValidateCommandLineOptionsAsync(new TestCommandLineOptions(options)).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportFileNameRequiresJUnitReport, result.ErrorMessage);
}

[TestMethod]
public async Task IsInvalid_If_JUnitReport_Used_With_DiscoverTests()
{
var provider = new JUnitReportGeneratorCommandLine();
var options = new Dictionary<string, string[]>
{
[JUnitReportGeneratorCommandLine.JUnitReportOptionName] = [],
[PlatformCommandLineProvider.DiscoverTestsOptionKey] = [],
};

ValidationResult result = await provider.ValidateCommandLineOptionsAsync(new TestCommandLineOptions(options)).ConfigureAwait(false);

Assert.IsFalse(result.IsValid);
Assert.AreEqual(JUnitReport.Resources.ExtensionResources.JUnitReportIsNotValidForDiscovery, result.ErrorMessage);
}

[TestMethod]
public async Task IsValid_When_JUnitReport_Used_Alone()
{
var provider = new JUnitReportGeneratorCommandLine();
var options = new Dictionary<string, string[]>
{
[JUnitReportGeneratorCommandLine.JUnitReportOptionName] = [],
};

ValidationResult result = await provider.ValidateCommandLineOptionsAsync(new TestCommandLineOptions(options)).ConfigureAwait(false);

Assert.IsTrue(result.IsValid);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@
<ProjectReference Include="$(RepoRoot)src\Platform\Microsoft.Testing.Extensions.HtmlReport\Microsoft.Testing.Extensions.HtmlReport.csproj">
<SetTargetFramework Condition="$([MSBuild]::GetTargetFrameworkIdentifier('$(TargetFramework)')) != '.NETCoreApp'">TargetFramework=netstandard2.0</SetTargetFramework>
</ProjectReference>
<ProjectReference Include="$(RepoRoot)src\Platform\Microsoft.Testing.Extensions.JUnitReport\Microsoft.Testing.Extensions.JUnitReport.csproj">
<SetTargetFramework Condition="$([MSBuild]::GetTargetFrameworkIdentifier('$(TargetFramework)')) != '.NETCoreApp'">TargetFramework=netstandard2.0</SetTargetFramework>
</ProjectReference>
<ProjectReference Include="$(RepoRoot)src\Platform\Microsoft.Testing.Extensions.Retry\Microsoft.Testing.Extensions.Retry.csproj">
<SetTargetFramework Condition="$([MSBuild]::GetTargetFrameworkIdentifier('$(TargetFramework)')) != '.NETCoreApp'">TargetFramework=netstandard2.0</SetTargetFramework>
</ProjectReference>
Expand Down