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
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,15 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Setup .NET Core
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
# The first dotnet command of a runner runs the NuGet migrations, which can fail when several dotnet commands start them at the same time
- run: dotnet nuget locals global-packages --list
# create_nuget skips the analyzers and dotnet test does not run them, so this job reports their diagnostics
- parallel:
# Meziantou.Analyzer.Pack references one project per Roslyn version, so this builds all of them
- run: dotnet build src/Meziantou.Analyzer.Pack/Meziantou.Analyzer.Pack.csproj --configuration Release /p:RunAnalyzers=true
- run: dotnet build src/Meziantou.Analyzer.Annotations/Meziantou.Analyzer.Annotations.csproj --configuration Release /p:RunAnalyzers=true
# The fuzz tests only run in the fuzz workflow, so this keeps them compiling
- run: dotnet build tests/Meziantou.Analyzer.Fuzz/Meziantou.Analyzer.Fuzz.csproj --configuration Release /p:RunAnalyzers=true

create_nuget:
runs-on: ubuntu-latest
Expand All @@ -105,6 +109,8 @@ jobs:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- name: Setup .NET Core
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6
# The first dotnet command of a runner runs the NuGet migrations, which can fail when several dotnet commands start them at the same time
- run: dotnet nuget locals global-packages --list

# The analyzers are skipped as the build_with_analyzers job runs them on the same projects
# Meziantou.Analyzer.Annotations does not depend on the analyzer, so it is packed while the analyzer is built and packed
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/close-issues.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,6 @@ jobs:
days-before-pr-close: 60
stale-pr-message: "This pull request is stale because it has been open for 60 days with no activity."
close-pr-message: "This pull request was closed because it has been inactive for 14 days since being marked as stale."
exempt-issue-labels: "not-stale,need-help"
exempt-issue-labels: "not-stale,need-help,fuzzing"
exempt-pr-labels: "not-stale,need-help"
repo-token: ${{ secrets.GITHUB_TOKEN }}
131 changes: 131 additions & 0 deletions .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Fuzz the analyzers and the code fixers, and open an issue for each new finding
# See tests/Meziantou.Analyzer.Fuzz/README.md
name: fuzz
on:
workflow_dispatch:
inputs:
duration-minutes:
description: "Duration of the fuzzing, in minutes"
type: number
default: 240
report:
description: "Open an issue for each new finding"
type: boolean
default: true
schedule:
- cron: "0 3 * * 0"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add timezone (america/toronto)

timezone: "America/Toronto"

concurrency:
group: fuzz
cancel-in-progress: false

env:
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1
DOTNET_NOLOGO: true
SHARPFUZZ_VERSION: 2.3.0
# https://github.com/Metalnem/libfuzzer-dotnet/releases
LIBFUZZER_DOTNET_URL: https://github.com/Metalnem/libfuzzer-dotnet/releases/download/v2025.05.02.0904/libfuzzer-dotnet-ubuntu
LIBFUZZER_DOTNET_SHA256: c2c2a90d94c409a4af339a0d4f244e0442c5a5d249be0f1252ba07871f285958

defaults:
run:
shell: pwsh

jobs:
fuzz:
# The scheduled runs would open issues in the forks
if: github.event_name == 'workflow_dispatch' || github.repository == 'meziantou/Meziantou.Analyzer'
runs-on: ubuntu-latest
timeout-minutes: 350
permissions:
contents: read
actions: read # Download the corpus of the previous run
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6

- name: Install SharpFuzz and libfuzzer-dotnet
run: |
dotnet tool install --global SharpFuzz.CommandLine --version $env:SHARPFUZZ_VERSION

$libFuzzer = Join-Path $env:RUNNER_TEMP "libfuzzer-dotnet"
Invoke-WebRequest $env:LIBFUZZER_DOTNET_URL -OutFile $libFuzzer
$hash = (Get-FileHash $libFuzzer -Algorithm SHA256).Hash
if ($hash -ne $env:LIBFUZZER_DOTNET_SHA256) {
Write-Error "Unexpected SHA-256 of libfuzzer-dotnet: $hash"
exit 1
}

chmod +x $libFuzzer
"LIBFUZZER=$libFuzzer" >> $env:GITHUB_ENV

- name: Download the corpus of the previous run
env:
GH_TOKEN: ${{ github.token }}
run: |
$directory = Join-Path $env:RUNNER_TEMP "previous-corpus"
$runs = gh run list --repo $env:GITHUB_REPOSITORY --workflow fuzz.yml --status completed --limit 10 --json databaseId | ConvertFrom-Json
foreach ($run in $runs) {
gh run download $run.databaseId --repo $env:GITHUB_REPOSITORY --name fuzz-corpus --dir $directory
if ($LASTEXITCODE -eq 0) {
Write-Host "Using the corpus of the run $($run.databaseId)"
"PREVIOUS_CORPUS=$directory" >> $env:GITHUB_ENV
break
}
}

exit 0

- name: Fuzz
env:
DURATION_MINUTES: ${{ inputs.duration-minutes || 240 }}
run: ./tests/Meziantou.Analyzer.Fuzz/eng/run.ps1 -LibFuzzer $env:LIBFUZZER -DurationMinutes $env:DURATION_MINUTES -OutputDirectory artifacts/fuzz -PreviousCorpus $env:PREVIOUS_CORPUS

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: fuzz-corpus
path: artifacts/fuzz/corpus-merged
if-no-files-found: ignore
retention-days: 30

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: fuzz-findings
path: artifacts/fuzz/findings
if-no-files-found: ignore
retention-days: 90

- uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
if: always()
with:
name: fuzz-logs
path: |
artifacts/fuzz/logs
artifacts/fuzz/artifacts
artifacts/fuzz/state/suppressed.txt
if-no-files-found: ignore
retention-days: 14

# A separate job, so the token that can write the issues is not available to the fuzzed code
report:
needs: fuzz
if: github.event_name == 'schedule' || inputs.report
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
continue-on-error: true # There is no artifact when there is no finding
with:
name: fuzz-findings
path: artifacts/fuzz/findings
- name: Open the issues
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: ./tests/Meziantou.Analyzer.Fuzz/eng/report.ps1 -FindingsDirectory artifacts/fuzz/findings -RunUrl $env:RUN_URL
2 changes: 2 additions & 0 deletions Meziantou.Analyzer.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
<Folder Name="/Solution Items/">
<File Path=".editorconfig" />
<File Path=".github/workflows/ci.yml" />
<File Path=".github/workflows/fuzz.yml" />
<File Path="CHANGELOG.md" />
<File Path="Directory.Build.props" />
<File Path="Directory.Build.targets" />
Expand All @@ -23,6 +24,7 @@
<Project Path="src/Meziantou.Analyzer/Meziantou.Analyzer.roslyn5.0.csproj" />
<Project Path="src/Meziantou.Analyzer/Meziantou.Analyzer.roslyn5.6.csproj" />
<Project Path="src/Meziantou.Analyzer/Meziantou.Analyzer.roslyn5.9.csproj" />
<Project Path="tests/Meziantou.Analyzer.Fuzz/Meziantou.Analyzer.Fuzz.csproj" />
<Project Path="tests/Meziantou.Analyzer.Test/Meziantou.Analyzer.Test.roslyn4.8.csproj" />
<Project Path="tests/Meziantou.Analyzer.Test/Meziantou.Analyzer.Test.roslyn4.14.csproj" />
<Project Path="tests/Meziantou.Analyzer.Test/Meziantou.Analyzer.Test.roslyn5.0.csproj" />
Expand Down
2 changes: 2 additions & 0 deletions src/ListDotNetTypes/ListDotNetTypes.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

<ItemGroup>
<PackageReference Include="NuGet.Protocol" Version="7.9.0" />
<!-- NuGet.Packaging, referenced by NuGet.Protocol, references a vulnerable version (GHSA-846f-f764-76mx, GHSA-2fmv-rv2c-59x7) -->
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="8.0.2" />
</ItemGroup>

</Project>
193 changes: 193 additions & 0 deletions tests/Meziantou.Analyzer.Fuzz/AnalysisEngine.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
using System.Collections.Concurrent;
using System.Collections.Immutable;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CodeActions;
using Microsoft.CodeAnalysis.CodeFixes;
using Microsoft.CodeAnalysis.Diagnostics;
using Microsoft.CodeAnalysis.Text;

namespace Meziantou.Analyzer.Fuzz;

/// <summary>
/// Run the analyzers on a C# file, then the code fixers on the diagnostics they report, and collect the defects.
/// </summary>
internal sealed class AnalysisEngine(ComponentFilter filter, int maxFixedDiagnostics = 20)
{
public async Task<IReadOnlyList<Finding>> RunAsync(string source, CancellationToken cancellationToken = default)
{
var findings = new List<Finding>();
try
{
using var workspace = new AdhocWorkspace();
var projectId = ProjectId.CreateNewId();
var documentId = DocumentId.CreateNewId(projectId);
var solution = workspace.CurrentSolution
.AddProject(ProjectInfo.Create(
projectId,
VersionStamp.Default,
name: "Fuzz",
assemblyName: "Fuzz",
LanguageNames.CSharp,
compilationOptions: Components.CompilationOptions,
parseOptions: Components.ParseOptions,
metadataReferences: Components.MetadataReferences,
analyzerReferences: Components.SourceGenerators))
.AddDocument(documentId, "Fuzz.cs", SourceText.From(source));

var document = solution.GetDocument(documentId)!;
var compilation = (await document.Project.GetCompilationAsync(cancellationToken).ConfigureAwait(false))!;
var diagnostics = await RunAnalyzersAsync(compilation, findings, cancellationToken).ConfigureAwait(false);
if (!filter.CodeFixProviders.IsEmpty)
{
var hasErrors = compilation.GetDiagnostics(cancellationToken).Any(diagnostic => diagnostic.Severity is DiagnosticSeverity.Error);
await RunCodeFixesAsync(document, diagnostics, hasErrors, findings, cancellationToken).ConfigureAwait(false);
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
findings.Add(Finding.FromException(FindingKind.HarnessException, "Meziantou.Analyzer.Fuzz", [], ex));
}

return [.. findings.DistinctBy(finding => finding.Signature)];
}

private async Task<ImmutableArray<Diagnostic>> RunAnalyzersAsync(Compilation compilation, List<Finding> findings, CancellationToken cancellationToken)
{
if (filter.Analyzers.IsEmpty)
return [];

var exceptions = new ConcurrentQueue<(DiagnosticAnalyzer Analyzer, Exception Exception)>();
var options = new CompilationWithAnalyzersOptions(
new AnalyzerOptions([]),
onAnalyzerException: (exception, analyzer, _) => exceptions.Enqueue((analyzer, exception)),
concurrentAnalysis: false, // Keep the coverage deterministic, so libFuzzer can tell which mutations are interesting
logAnalyzerExecutionTime: false,
reportSuppressedDiagnostics: false);

var compilationWithAnalyzers = compilation.WithAnalyzers(filter.Analyzers, options);
var diagnostics = await compilationWithAnalyzers.GetAllDiagnosticsAsync(cancellationToken).ConfigureAwait(false);
foreach (var (analyzer, exception) in exceptions)
{
if (exception is OperationCanceledException)
continue;

findings.Add(Finding.FromException(FindingKind.AnalyzerException, analyzer, Components.GetDiagnosticIds(analyzer), exception));
}

return diagnostics;
}

private async Task RunCodeFixesAsync(Document document, ImmutableArray<Diagnostic> diagnostics, bool hasErrors, List<Finding> findings, CancellationToken cancellationToken)
{
var fixableDiagnostics = diagnostics
.Where(diagnostic => diagnostic.Location.IsInSource && !diagnostic.IsSuppressed && filter.CodeFixProviders.ContainsKey(diagnostic.Id))
.DistinctBy(diagnostic => (diagnostic.Id, diagnostic.Location.SourceSpan))
.Take(maxFixedDiagnostics);

foreach (var diagnostic in fixableDiagnostics)
{
foreach (var provider in filter.CodeFixProviders[diagnostic.Id])
{
await RunCodeFixAsync(document, provider, diagnostic, hasErrors, findings, cancellationToken).ConfigureAwait(false);
}
}
}

private static async Task RunCodeFixAsync(Document document, CodeFixProvider provider, Diagnostic diagnostic, bool hasErrors, List<Finding> findings, CancellationToken cancellationToken)
{
try
{
var actions = new List<CodeAction>();
var context = new CodeFixContext(document, diagnostic, (action, _) => actions.Add(action), cancellationToken);
await provider.RegisterCodeFixesAsync(context).ConfigureAwait(false);

foreach (var action in Flatten(actions))
{
var operations = await action.GetOperationsAsync(cancellationToken).ConfigureAwait(false);
var changedSolution = operations.OfType<ApplyChangesOperation>().LastOrDefault()?.ChangedSolution;
if (changedSolution is null || !await HasTextChangesAsync(document.Project.Solution, changedSolution, cancellationToken).ConfigureAwait(false))
{
findings.Add(new Finding
{
Kind = FindingKind.CodeFixDoesNotChangeCode,
Component = Components.GetName(provider),
DiagnosticIds = [diagnostic.Id],
CodeActionTitle = action.Title,
});
continue;
}

// The fuzzed code rarely compiles, and a code fix cannot be expected to fix the errors of the code
if (hasErrors || changedSolution.GetProject(document.Project.Id) is not { } changedProject)
continue;

var changedCompilation = (await changedProject.GetCompilationAsync(cancellationToken).ConfigureAwait(false))!;
var errors = changedCompilation.GetDiagnostics(cancellationToken).Where(diagnostic => diagnostic.Severity is DiagnosticSeverity.Error).ToList();
if (errors.Count > 0)
{
var fixedDocument = changedSolution.GetDocument(document.Id);
findings.Add(new Finding
{
Kind = FindingKind.CodeFixBreaksCode,
Component = Components.GetName(provider),
DiagnosticIds = [diagnostic.Id],
CompilerErrors = [.. errors.Select(error => error.Id).Distinct(StringComparer.Ordinal).Order(StringComparer.Ordinal)],
Message = string.Join('\n', errors.Take(10).Select(error => error.ToString())),
CodeActionTitle = action.Title,
FixedSource = fixedDocument is null ? null : (await fixedDocument.GetTextAsync(cancellationToken).ConfigureAwait(false)).ToString(),
});
}
}
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
findings.Add(Finding.FromException(FindingKind.CodeFixException, provider, [diagnostic.Id], ex));
}
}

private static IEnumerable<CodeAction> Flatten(IEnumerable<CodeAction> actions)
{
foreach (var action in actions)
{
if (action.NestedActions.IsEmpty)
{
yield return action;
continue;
}

foreach (var nestedAction in Flatten(action.NestedActions))
{
yield return nestedAction;
}
}
}

private static async Task<bool> HasTextChangesAsync(Solution originalSolution, Solution changedSolution, CancellationToken cancellationToken)
{
var changes = changedSolution.GetChanges(originalSolution);
if (changes.GetAddedProjects().Any() || changes.GetRemovedProjects().Any())
return true;

foreach (var projectChanges in changes.GetProjectChanges())
{
if (projectChanges.GetAddedDocuments().Any() || projectChanges.GetRemovedDocuments().Any()
|| projectChanges.GetAddedAdditionalDocuments().Any() || projectChanges.GetRemovedAdditionalDocuments().Any()
|| projectChanges.GetChangedAdditionalDocuments().Any() || projectChanges.GetAddedAnalyzerConfigDocuments().Any()
|| projectChanges.GetRemovedAnalyzerConfigDocuments().Any() || projectChanges.GetChangedAnalyzerConfigDocuments().Any()
|| projectChanges.GetAddedMetadataReferences().Any() || projectChanges.GetRemovedMetadataReferences().Any())
{
return true;
}

foreach (var documentId in projectChanges.GetChangedDocuments(onlyGetDocumentsWithTextChanges: true))
{
var originalText = await originalSolution.GetDocument(documentId)!.GetTextAsync(cancellationToken).ConfigureAwait(false);
var changedText = await changedSolution.GetDocument(documentId)!.GetTextAsync(cancellationToken).ConfigureAwait(false);
if (!originalText.ContentEquals(changedText))
return true;
}
}

return false;
}
}
Loading
Loading