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
9 changes: 9 additions & 0 deletions Documentation/Coverlet.MTP.Integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ dotnet exec <test-assembly.dll> --help
| :------- | :------------ |
| `--coverlet` | Enable code coverage data collection. |
| `--coverlet-output-format <format>` | Output format(s) for coverage report. Supported formats: `json`, `lcov`, `opencover`, `cobertura`, `teamcity`. Can be specified multiple times. (default: `json`, `cobertura`) |
| `--coverlet-file-prefix <prefix>` | Prefix for coverage report filenames to prevent overwrites when multiple test projects write to the same directory. When specified, files are named `<prefix>.coverage.<extension>` instead of `coverage.<extension>`. (default: `none`) |
| `--coverlet-include <filter>` | Include assemblies matching filters (e.g., `[Assembly]Type`). Can be specified multiple times. (default: `none`) |
| `--coverlet-include-directory <path>` | Include additional directories for sources. Can be specified multiple times. (default: `none`) |
| `--coverlet-exclude <filter>` | Exclude assemblies matching filters (e.g., `[Assembly]Type`). Can be specified multiple times. User-specified filters are merged with defaults. (default: `[coverlet.*]*`, `[xunit.*]*`, `[NUnit3.*]*`, `[nunit.*]*`, `[Microsoft.Testing.*]*`, `[Microsoft.Testplatform.*]*`, `[Microsoft.VisualStudio.TestPlatform.*]*`) |
Expand Down Expand Up @@ -192,6 +193,14 @@ dotnet exec TestProject.dll --coverlet --coverlet-exclude "[.Tests]" --coverlet-
dotnet exec TestProject.dll --coverlet --coverlet-exclude-by-attribute "Obsolete" --coverlet-exclude-by-attribute "GeneratedCode"
```

**Use file prefix to prevent overwrites in multi-project solutions:**

```bash
dotnet exec TestProject.dll --coverlet --coverlet-file-prefix "MyProject.UnitTests"
```

This generates files named `MyProject.UnitTests.coverage.json` and `MyProject.UnitTests.coverage.cobertura.xml` instead of overwriting the default `coverage.json` and `coverage.cobertura.xml`.

## Coverage Output

Coverlet can generate coverage results in multiple formats:
Expand Down
55 changes: 52 additions & 3 deletions src/coverlet.MTP/Collector/CollectorExtension.cs
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ Task ITestHostProcessLifetimeHandler.BeforeTestHostProcessStartAsync(Cancellatio
_configuration.SingleHit = config.UseSingleHit;
_configuration.SkipAutoProps = config.SkipAutoProps;
_configuration.formats = config.GetOutputFormats();
_configuration.FilePrefix = config.GetFilePrefix();
_configuration.UseSourceLink = false;

_logger.LogVerbose($"Test module path: {_testModulePath}");
Expand Down Expand Up @@ -403,7 +404,8 @@ internal List<string> GenerateCoverageReportFiles(
ISourceRootTranslator sourceRootTranslator,
IFileSystem fileSystem,
string outputDirectory,
string[] formats)
string[] formats,
string? filePrefix = null)
{
var generatedReports = new List<string>();

Expand All @@ -420,7 +422,11 @@ internal List<string> GenerateCoverageReportFiles(
}
else
{
string filename = $"coverage.{reporter.Extension}";
// Defensive validation of filePrefix to prevent path traversal
string? sanitizedPrefix = SanitizeFilePrefix(filePrefix);
string filename = string.IsNullOrEmpty(sanitizedPrefix)
? $"coverage.{reporter.Extension}"
: $"{sanitizedPrefix}.coverage.{reporter.Extension}";
string reportPath = Path.Combine(outputDirectory, filename);
fileSystem.WriteAllText(reportPath, reporter.Report(result, sourceRootTranslator));
generatedReports.Add(reportPath);
Expand Down Expand Up @@ -483,7 +489,8 @@ private async Task GenerateReportsAsync(CoverageResult result, CancellationToken
sourceRootTranslator,
fileSystem,
outputDirectory,
_configuration.formats);
_configuration.formats,
_configuration.FilePrefix);

// Display results
await DisplayGeneratedReportsAsync(generatedReports, cancellation);
Expand All @@ -501,6 +508,48 @@ private string GetHitsFilePath()
return directory ?? string.Empty;
}

/// <summary>
/// Sanitizes the file prefix to ensure it's a safe filename segment.
/// Returns null if the prefix is invalid or empty.
/// </summary>
private static string? SanitizeFilePrefix(string? filePrefix)
{
if (string.IsNullOrWhiteSpace(filePrefix))
{
return null;
}

// At this point, filePrefix is guaranteed to be non-null and non-whitespace
string prefix = filePrefix!;

// Reject directory separators (path traversal prevention)
if (prefix.Contains('/') || prefix.Contains('\\'))
{
return null;
}

// Reject rooted paths
if (Path.IsPathRooted(prefix))
{
return null;
}

// Reject path traversal patterns
if (prefix.Equals("..", StringComparison.Ordinal) || prefix.StartsWith("..", StringComparison.Ordinal))
{
return null;
}

// Reject invalid filename characters
char[] invalidChars = Path.GetInvalidFileNameChars();
if (prefix.IndexOfAny(invalidChars) >= 0)
{
return null;
}

return prefix;
}

private string? ResolveTestModulePath()
{
// Try platform configuration
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public static IReadOnlyCollection<CommandLineOption> GetAllOptions()
[
new CommandLineOption(CoverletOptionNames.Coverage, "Enable code coverage data collection.", ArgumentArity.Zero, isHidden: false),
new CommandLineOption(CoverletOptionNames.Formats, "Output format(s) for coverage report (json, lcov, opencover, cobertura).", ArgumentArity.OneOrMore, isHidden: false),
new CommandLineOption(CoverletOptionNames.FilePrefix, "Prefix for coverage report filenames to prevent overwrites when multiple test projects write to the same directory.", ArgumentArity.ExactlyOne, isHidden: false),
new CommandLineOption(CoverletOptionNames.Include, "Include assemblies matching filters (e.g., [Assembly]Type).", ArgumentArity.OneOrMore, isHidden: false),
new CommandLineOption(CoverletOptionNames.IncludeDirectory, "Include additional directories for instrumentation.", ArgumentArity.OneOrMore, isHidden: false),
new CommandLineOption(CoverletOptionNames.Exclude, "Exclude assemblies matching filters (e.g., [Assembly]Type).", ArgumentArity.OneOrMore, isHidden: false),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,19 @@ public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption com
return ValidationResult.ValidTask;
}

if (commandOption.Name == CoverletOptionNames.FilePrefix)
{
if (arguments.Length > 0 && !string.IsNullOrWhiteSpace(arguments[0]))
{
string? validationError = ValidateFilePrefix(arguments[0]);
if (validationError is not null)
{
return Task.FromResult(ValidationResult.Invalid(validationError));
}
}
return ValidationResult.ValidTask;
}

if (commandOption.Name == CoverletOptionNames.ExcludeAssembliesWithoutSources)
{
if (arguments.Length == 0)
Expand All @@ -62,6 +75,42 @@ public Task<ValidationResult> ValidateOptionArgumentsAsync(CommandLineOption com
return ValidationResult.ValidTask;
}

/// <summary>
/// Validates that the file prefix is a safe filename segment without path traversal risks.
/// </summary>
/// <param name="filePrefix">The file prefix to validate.</param>
/// <returns>An error message if invalid, or null if valid.</returns>
internal static string? ValidateFilePrefix(string filePrefix)
{
// Check for rooted paths (e.g., "C:" on Windows or starting with "/")
if (Path.IsPathRooted(filePrefix))
{
return $"The file prefix '{filePrefix}' must not be a rooted path.";
}

// Check for directory separators (path traversal)
if (filePrefix.Contains(Path.DirectorySeparatorChar))
{
return $"The file prefix '{filePrefix}' must not contain directory separators.";
}

// Check for invalid filename characters
char[] invalidChars = Path.GetInvalidFileNameChars();
int invalidIndex = filePrefix.IndexOfAny(invalidChars);
if (invalidIndex >= 0)
{
return $"The file prefix '{filePrefix}' contains invalid character '{filePrefix[invalidIndex]}'.";
}

// Check for path traversal patterns
if (filePrefix == ".." || filePrefix.StartsWith("..", StringComparison.Ordinal))
{
return $"The file prefix '{filePrefix}' must not contain path traversal patterns.";
}

return null;
}

public Task<ValidationResult> ValidateCommandLineOptionsAsync(Microsoft.Testing.Platform.CommandLine.ICommandLineOptions commandLineOptions)
{
return ValidationResult.ValidTask;
Expand Down
1 change: 1 addition & 0 deletions src/coverlet.MTP/CommandLine/CoverletOptionNames.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ internal static class CoverletOptionNames
{
public const string Coverage = "coverlet";
public const string Formats = "coverlet-output-format";
public const string FilePrefix = "coverlet-file-prefix";
public const string Include = "coverlet-include";
public const string Exclude = "coverlet-exclude";
public const string ExcludeByFile = "coverlet-exclude-by-file";
Expand Down
24 changes: 24 additions & 0 deletions src/coverlet.MTP/Configuration/CoverageConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,30 @@ public string[] GetDoesNotReturnAttributes()
return [];
}

/// <summary>
/// Gets the file prefix for coverage report filenames.
/// Returns null if the prefix is empty or whitespace.
/// </summary>
public string? GetFilePrefix()
{
if (_commandLineOptions.TryGetOptionArgumentList(
CoverletOptionNames.FilePrefix,
out string[]? prefix) && prefix.Length > 0)
{
string? rawPrefix = prefix[0];
string? trimmedPrefix = rawPrefix?.Trim();
if (string.IsNullOrWhiteSpace(trimmedPrefix))
{
return null;
}

LogOptionValue(CoverletOptionNames.FilePrefix, [trimmedPrefix!], isExplicit: true);
return trimmedPrefix;
}

return null;
}

/// <summary>
/// Gets the test assembly path.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ internal class CoverletExtensionConfiguration
{
public string[] formats { get; set; } = ["json"];
/// <summary>
/// Optional prefix for coverage report filenames to prevent overwrites.
/// </summary>
public string? FilePrefix { get; set; }
/// <summary>
/// Test module
/// </summary>
public string? TestModule { get; set; }
Expand Down
Loading
Loading