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,6 +2,7 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

using Microsoft.Testing.Extensions.HtmlReport.Resources;
using Microsoft.Testing.Platform;
using Microsoft.Testing.Platform.CommandLine;
using Microsoft.Testing.Platform.Configurations;
using Microsoft.Testing.Platform.Extensions.TestFramework;
Expand Down Expand Up @@ -64,11 +65,19 @@ public HtmlReportEngine(
out string[]? providedFileName);

string fileName = fileNameExplicitlyProvided
? providedFileName![0]
? ResolveHtmlFileName(GetProvidedFileName(providedFileName))
: BuildDefaultFileName(finishTime);

string outputDirectory = _configuration.GetTestResultDirectory();
// Path.Combine short-circuits when the second argument is rooted, so an absolute
// user-provided file name overrides the test results directory while validated
// relative paths stay nested under it.
string finalPath = Path.Combine(outputDirectory, fileName);
string? finalDirectory = Path.GetDirectoryName(finalPath);
if (!RoslynString.IsNullOrEmpty(finalDirectory))
{
_fileSystem.CreateDirectory(finalDirectory);
}

string template = LoadTemplate();
string json = BuildJson(results, finishTime);
Expand All @@ -82,6 +91,11 @@ public HtmlReportEngine(
return await WriteWithRetryAsync(finalPath, bytes, fileNameExplicitlyProvided).ConfigureAwait(false);
}

private static string GetProvidedFileName(string[]? providedFileName)
=> providedFileName is { Length: > 0 }
? providedFileName[0]
: throw ApplicationStateGuard.Unreachable();

private async Task<(string FileName, string? Warning)> WriteWithRetryAsync(string finalPath, byte[] bytes, bool fileNameExplicitlyProvided)
{
// Explicit file names: use FileMode.Create (overwrite). Default-generated file
Expand Down Expand Up @@ -154,6 +168,19 @@ private string BuildDefaultFileName(DateTimeOffset finishTime)
return ReplaceInvalidFileNameChars(raw);
}

private string ResolveHtmlFileName(string template)
{
string processName = Path.GetFileNameWithoutExtension(_testApplicationModuleInfo.GetCurrentTestApplicationFullPath());
string processId = _environment.ProcessId.ToString(CultureInfo.InvariantCulture);
Dictionary<string, string> replacements = ArtifactNamingHelper.GetStandardReplacements(processName, processId, _clock.UtcNow);
string resolved = ArtifactNamingHelper.ResolveTemplate(template, replacements);
string directoryPart = Path.GetDirectoryName(resolved) ?? string.Empty;
string sanitizedFileName = ReplaceInvalidFileNameChars(Path.GetFileName(resolved));
return directoryPart.Length == 0
? sanitizedFileName
: Path.Combine(directoryPart, sanitizedFileName);
}

private static string GetTargetFrameworkMoniker()
=> TargetFrameworkParser.GetShortTargetFramework(
Assembly.GetEntryAssembly()?.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkDisplayName)
Expand All @@ -163,13 +190,46 @@ private static string GetTargetFrameworkMoniker()
private static string ReplaceInvalidFileNameChars(string fileName)
{
var sb = new StringBuilder(fileName.Length);
char[] invalid = Path.GetInvalidFileNameChars();
foreach (char c in fileName)
{
sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c);
sb.Append(IsInvalidFileNameChar(c) ? '_' : c);
}

return sb.ToString();
string replaced = sb.ToString().TrimEnd();
if (IsReservedFileName(replaced))
{
replaced = '_' + replaced;
}

return replaced;
}

private static bool IsInvalidFileNameChar(char c)
// Keep the explicit file-name sanitization aligned with TRX report naming so
// placeholders and cross-platform reserved characters produce compatible names.
=> c is < ' ' or '"' or '<' or '>' or '|' or ':' or '*' or '?' or '\\' or '/' or '@' or '(' or ')' or '^' or ' ';

private static bool IsReservedFileName(string fileName)
{
string bareName = fileName;
int dot = bareName.IndexOf('.');
if (dot >= 0)
{
bareName = bareName.Substring(0, dot);
}

return bareName.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| bareName.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| bareName.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| bareName.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| bareName.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase)
|| IsReservedNameWithNumber(bareName, "COM")
|| IsReservedNameWithNumber(bareName, "LPT");

static bool IsReservedNameWithNumber(string bareName, string prefix)
=> bareName.Length == 4
&& bareName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& bareName[3] is >= '1' and <= '9';
}

private static string LoadTemplate()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ internal sealed class HtmlReportGeneratorCommandLine : ICommandLineOptionsProvid
public const string HtmlReportOptionName = "report-html";
public const string HtmlReportFileNameOptionName = "report-html-filename";

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

/// <inheritdoc />
public string Uid => nameof(HtmlReportGeneratorCommandLine);

Expand All @@ -40,19 +42,28 @@ public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption com
{
if (commandOption.Name == HtmlReportFileNameOptionName)
{
string fileName = arguments[0];
if (arguments.Length is 0)
{
return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameMustNotBeEmpty);
}

// Validate "pure file name" first. We don't want any path component, drive letter,
// parent directory traversal, leading/trailing whitespace or invalid file name char.
if (!IsValidPureFileName(fileName))
string argument = arguments[0];

string fileNamePart = Path.GetFileName(argument);
if (RoslynString.IsNullOrWhiteSpace(fileNamePart))
{
return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameShouldNotContainPath);
return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameMustNotBeEmpty);
}
Comment thread
Evangelink marked this conversation as resolved.

if (!fileName.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
if (!fileNamePart.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
{
return ValidationResult.InvalidTask(ExtensionResources.HtmlReportFileNameExtensionIsNotHtml);
}

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

return ValidationResult.ValidTask;
Expand All @@ -65,75 +76,64 @@ public Task<ValidationResult> ValidateCommandLineOptionsAsync(ICommandLineOption
? ValidationResult.InvalidTask(ExtensionResources.HtmlReportIsNotValidForDiscovery)
: ValidationResult.ValidTask;

// We are intentionally strict here so that we cannot be tricked across platforms.
// The argument must be a "pure" file name: no directory separator, no drive letter,
// no parent directory traversal, no invalid file name character, no leading/trailing
// whitespace, no Windows reserved device name. We use a hard-coded list of invalid
// characters (a superset of Path.GetInvalidFileNameChars() on Linux + Windows) so
// the same input is rejected regardless of the host OS.
private static readonly char[] InvalidFileNameChars =
[
'\0', '/', '\\', ':', '*', '?', '"', '<', '>', '|',
'\u0001', '\u0002', '\u0003', '\u0004', '\u0005', '\u0006', '\u0007',
'\b', '\t', '\n', '\u000b', '\u000c', '\r',
'\u000e', '\u000f', '\u0010', '\u0011', '\u0012', '\u0013', '\u0014',
'\u0015', '\u0016', '\u0017', '\u0018', '\u0019', '\u001a', '\u001b',
'\u001c', '\u001d', '\u001e', '\u001f',
];

// Windows reserved device names. CreateFile on Windows will redirect a file
// named e.g. CON.html to the actual device. Rejecting them up-front means the
// option doesn't pass validation but then explode later in WriteAsync.
private static readonly string[] WindowsReservedNames =
[
"CON", "PRN", "AUX", "NUL",
"COM0", "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT0", "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9",
];

private static bool IsValidPureFileName(string fileName)
private static bool EscapesResultsDirectory(string path)
{
if (RoslynString.IsNullOrWhiteSpace(fileName))
// Fully-qualified paths (e.g. "C:\foo.html", "\\server\share\foo.html" or "/foo.html") 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;
}

if (fileName != fileName.Trim())
// Drive-relative paths on Windows such as "C:foo.html" 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 false;
return true;
}

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

Comment thread
Evangelink marked this conversation as resolved.
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;
}

foreach (char c in fileName)
// UNC paths like "\\server\share" (or with forward slashes).
if (IsDirectorySeparator(path[0]) && IsDirectorySeparator(path[1]))
{
if (Array.IndexOf(InvalidFileNameChars, c) >= 0)
{
return false;
}
return true;
}

// Disallow Windows device names independent of host OS so the option is
// consistently rejected. We compare against the bare name (without extension)
// because e.g. "CON.html" maps to the CON device.
string bareName = fileName;
int dot = bareName.IndexOf('.');
if (dot >= 0)
// On Unix, only paths starting with "/" are fully qualified.
if (Path.DirectorySeparatorChar == '/')
{
bareName = bareName.Substring(0, dot);
return path[0] == '/';
}

foreach (string reserved in WindowsReservedNames)
{
if (string.Equals(bareName, reserved, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
// 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;

return true;
static bool IsValidDriveLetter(char c)
=> c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z');
#endif
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ This package extends Microsoft Testing Platform to produce self-contained HTML t
<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\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" />
</ItemGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,15 +70,20 @@
<data name="HtmlReportFileNameExtensionIsNotHtml" xml:space="preserve">
<value>'--report-html-filename' file name argument must end with '.html' (e.g. --report-html-filename myreport.html)</value>
</data>
<data name="HtmlReportFileNameMustNotBeEmpty" xml:space="preserve">
<value>'--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html)</value>
</data>
<data name="HtmlReportFileNameOptionDescription" xml:space="preserve">
<value>The name of the generated HTML report</value>
<value>The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created.
Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp).
Example: MyReport_{tfm}.html</value>
</data>
<data name="HtmlReportFileNameRelativePathMustStayUnderResultsDirectory" xml:space="preserve">
<value>'--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html)</value>
</data>
<data name="HtmlReportFileNameRequiresHtmlReport" xml:space="preserve">
<value>'--report-html-filename' requires '--report-html' to be enabled</value>
</data>
<data name="HtmlReportFileNameShouldNotContainPath" xml:space="preserve">
<value>file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html)</value>
</data>
<data name="HtmlReportGeneratorDescription" xml:space="preserve">
<value>Produce a self-contained HTML report for the current test session</value>
</data>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,21 +22,28 @@
<target state="translated">Argument názvu souboru „--report-html-filename“ musí končit na .html (např. --report-html-filename myreport.html)</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameMustNotBeEmpty">
<source>'--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html)</source>
<target state="new">'--report-html-filename' file name part must not be empty (e.g. --report-html-filename myreport.html)</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameOptionDescription">
<source>The name of the generated HTML report</source>
<target state="translated">Název vygenerované sestavy HTML</target>
<source>The name of the generated HTML report. May include a relative or absolute path; relative paths are resolved against the test results directory and missing directories are created.
Supports the following placeholders: {pname} (test application name), {pid} (process ID), {asm} (entry assembly name), {tfm} (target framework moniker), {time} (timestamp).
Example: MyReport_{tfm}.html</source>
<target state="needs-review-translation">Název vygenerované sestavy HTML</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameRelativePathMustStayUnderResultsDirectory">
<source>'--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html)</source>
<target state="new">'--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename nested/myreport.html)</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameRequiresHtmlReport">
<source>'--report-html-filename' requires '--report-html' to be enabled</source>
<target state="translated">„--report-html-filename“ vyžaduje povolení „--report-html“</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameShouldNotContainPath">
<source>file name argument must not contain a path or invalid characters (e.g. --report-html-filename myreport.html)</source>
<target state="translated">Argument názvu souboru nesmí obsahovat cestu nebo neplatné znaky (např. --report-html-filename myreport.html)</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportGeneratorDescription">
<source>Produce a self-contained HTML report for the current test session</source>
<target state="translated">Vytvoření samostatné sestavy HTML pro aktuální testovací relaci</target>
Expand Down
Loading
Loading