Skip to content
Draft
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.HtmlReportFileNameShouldNotContainPath);
}

// 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);
}

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

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

return ValidationResult.ValidTask;
Expand All @@ -65,75 +76,43 @@ 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)
{
if (RoslynString.IsNullOrWhiteSpace(fileName))
{
return false;
}
private static bool EscapesResultsDirectory(string path)
=> !IsPathFullyQualified(path)
&& (IsDriveRelativePath(path)
|| Path.IsPathRooted(path)
|| path.Split(DirectorySeparators, StringSplitOptions.RemoveEmptyEntries).Any(segment => segment == ".."));

if (fileName != fileName.Trim())
{
return false;
}
private static bool IsDriveRelativePath(string path)
=> path.Length >= 2
&& IsValidDriveLetter(path[0])
&& path[1] == ':'
&& (path.Length == 2 || !IsAnyDirectorySeparator(path[2]));

if (fileName == "." || fileName == ".." || fileName.Contains(".."))
{
return false;
}

foreach (char c in fileName)
{
if (Array.IndexOf(InvalidFileNameChars, c) >= 0)
{
return false;
}
}

// 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)
{
bareName = bareName.Substring(0, dot);
}
private static bool IsPathFullyQualified(string path)
{
#if NETCOREAPP
return Path.IsPathFullyQualified(path);
#else
return path.Length >= 2
&& ((IsDirectorySeparator(path[0]) && IsDirectorySeparator(path[1]))
|| (Path.DirectorySeparatorChar == '/'
? path[0] == '/'
: 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
}

foreach (string reserved in WindowsReservedNames)
{
if (string.Equals(bareName, reserved, StringComparison.OrdinalIgnoreCase))
{
return false;
}
}
private static bool IsAnyDirectorySeparator(char c)
=> c is '/' or '\\';

return true;
}
private static bool IsValidDriveLetter(char c)
=> c is (>= 'A' and <= 'Z') or (>= 'a' and <= 'z');
}
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 @@ -71,13 +71,15 @@
<value>'--report-html-filename' file name argument must end with '.html' (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="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>
<value>'--report-html-filename' relative paths must stay under the test results directory (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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
<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="HtmlReportFileNameRequiresHtmlReport">
Expand All @@ -33,8 +35,8 @@
<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>
<source>'--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename myreport.html)</source>
<target state="needs-review-translation">Argument názvu souboru nesmí obsahovat cestu nebo neplatné znaky (např. --report-html-filename myreport.html)</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportGeneratorDescription">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameOptionDescription">
<source>The name of the generated HTML report</source>
<target state="translated">Der Name des generierten HTML-Berichts.</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">Der Name des generierten HTML-Berichts.</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameRequiresHtmlReport">
Expand All @@ -33,8 +35,8 @@
<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">Das Dateinamenargument darf keinen Pfad und keine ungültigen Zeichen enthalten (z. B. --report-html-filename myreport.html).</target>
<source>'--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename myreport.html)</source>
<target state="needs-review-translation">Das Dateinamenargument darf keinen Pfad und keine ungültigen Zeichen enthalten (z. B. --report-html-filename myreport.html).</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportGeneratorDescription">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameOptionDescription">
<source>The name of the generated HTML report</source>
<target state="translated">Nombre del informe HTML generado</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">Nombre del informe HTML generado</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportFileNameRequiresHtmlReport">
Expand All @@ -33,8 +35,8 @@
<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">el argumento de nombre de archivo no debe contener una ruta de acceso ni caracteres no válidos (por ejemplo, --report-html-filename myreport.html)</target>
<source>'--report-html-filename' relative paths must stay under the test results directory (e.g. --report-html-filename myreport.html)</source>
<target state="needs-review-translation">el argumento de nombre de archivo no debe contener una ruta de acceso ni caracteres no válidos (por ejemplo, --report-html-filename myreport.html)</target>
<note />
</trans-unit>
<trans-unit id="HtmlReportGeneratorDescription">
Expand Down
Loading
Loading