mirror of https://github.com/icsharpcode/ILSpy.git
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1643 lines
66 KiB
1643 lines
66 KiB
// Copyright (c) 2026 Siegfried Pammer |
|
// |
|
// Permission is hereby granted, free of charge, to any person obtaining a copy of this |
|
// software and associated documentation files (the "Software"), to deal in the Software |
|
// without restriction, including without limitation the rights to use, copy, modify, merge, |
|
// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons |
|
// to whom the Software is furnished to do so, subject to the following conditions: |
|
// |
|
// The above copyright notice and this permission notice shall be included in all copies or |
|
// substantial portions of the Software. |
|
// |
|
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
|
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR |
|
// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE |
|
// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR |
|
// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER |
|
// DEALINGS IN THE SOFTWARE. |
|
|
|
#:project ../ICSharpCode.Decompiler/ICSharpCode.Decompiler.csproj |
|
#:package NuGet.Packaging@* |
|
#:package Mono.Cecil@0.11.6 |
|
#:property PublishAot=false |
|
|
|
// nugetfuzz: downloads nuget packages (sequentially), resolves their dependency |
|
// closure, picks a lib TFM (installed runtime, or classic .NET Framework via the |
|
// Microsoft.NETFramework.ReferenceAssemblies packages), then decompiles every |
|
// assembly type-by-type and reports Debug.Assert failures / exceptions. |
|
// |
|
// With --pdb it instead generates a portable PDB for each assembly and checks it: Mono.Cecil |
|
// (the consumer ILLink uses) has to be able to read every method body through it, and the PDB |
|
// metadata has to satisfy a structural lint. --pdb-lint runs the same checks against a PDB that |
|
// already exists next to the assembly, which is how the lint is calibrated: it must report |
|
// nothing at all for a PDB the C# compiler wrote. |
|
// |
|
// usage: dotnet run nugetfuzz.cs -- [--download-only|--pdb] <PackageId[@Version]>... | @packagelist.txt |
|
// dotnet run nugetfuzz.cs -- --pdb-lint <file.dll|dir>... | @corpus.txt |
|
|
|
using System.Diagnostics; |
|
using System.IO.Compression; |
|
using System.Reflection.Metadata; |
|
using System.Reflection.Metadata.Ecma335; |
|
using System.Reflection.PortableExecutable; |
|
using System.Net.Http.Json; |
|
using System.Text; |
|
using System.Text.Json; |
|
using System.Text.RegularExpressions; |
|
|
|
using ICSharpCode.Decompiler; |
|
using ICSharpCode.Decompiler.CSharp; |
|
using ICSharpCode.Decompiler.CSharp.OutputVisitor; |
|
using ICSharpCode.Decompiler.CSharp.Syntax; |
|
using ICSharpCode.Decompiler.DebugInfo; |
|
using ICSharpCode.Decompiler.Metadata; |
|
|
|
// Cecil is used through aliases: its MethodDefinition/SequencePoint/MethodBody names collide with |
|
// System.Reflection.Metadata's and the decompiler's, and every one of the three is used here. |
|
using Cecil = Mono.Cecil; |
|
using CecilCil = Mono.Cecil.Cil; |
|
|
|
using NuGet.Frameworks; |
|
using NuGet.Packaging; |
|
using NuGet.Versioning; |
|
|
|
Trace.Listeners.Clear(); |
|
Trace.Listeners.Add(new ThrowOnAssert()); |
|
try |
|
{ |
|
Debug.Fail("self-test"); |
|
Console.Error.WriteLine("FATAL: assert hook not active, Debug.Assert would go unreported"); |
|
return 2; |
|
} |
|
catch (AssertionFailedException) |
|
{ |
|
// hook works |
|
} |
|
|
|
var http = new HttpClient(); |
|
http.DefaultRequestHeaders.UserAgent.ParseAdd("nugetfuzz/1.0"); |
|
var cacheRoot = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".cache", "nugetfuzz"); |
|
var globalPackagesRoot = Environment.GetEnvironmentVariable("NUGET_PACKAGES") |
|
?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".nuget", "packages"); |
|
var installedTfm = NuGetFramework.Parse($"net{Environment.Version.Major}.{Environment.Version.Minor}"); |
|
var net48 = NuGetFramework.Parse("net48"); |
|
var reducer = new FrameworkReducer(); |
|
var failures = new Dictionary<string, Finding>(); |
|
var formatting = new DecompilerSettings().CSharpFormattingOptions; |
|
int assemblyCount = 0, typeCount = 0, pdbChecked = 0, pdbSkipped = 0; |
|
long charCount = 0, refsResolved = 0, refsTotal = 0; |
|
bool verbose = Environment.GetEnvironmentVariable("NUGETFUZZ_VERBOSE") != null; |
|
var dumpDir = Environment.GetEnvironmentVariable("NUGETFUZZ_DUMP"); |
|
if (dumpDir != null) |
|
Directory.CreateDirectory(dumpDir); |
|
var versionCache = new Dictionary<string, List<NuGetVersion>>(); |
|
var noSuchPackage = new HashSet<string>(); |
|
|
|
// Legacy framework-satellite assemblies whose nuget package id differs from the assembly name. |
|
var assemblyPackageAlias = new Dictionary<string, string> { |
|
["System.Web.Mvc"] = "Microsoft.AspNet.Mvc", |
|
["System.Web.Razor"] = "Microsoft.AspNet.Razor", |
|
["System.Web.WebPages"] = "Microsoft.AspNet.WebPages", |
|
["System.Web.WebPages.Razor"] = "Microsoft.AspNet.WebPages", |
|
["System.Web.WebPages.Deployment"] = "Microsoft.AspNet.WebPages", |
|
["System.Web.Helpers"] = "Microsoft.AspNet.WebPages", |
|
["System.Web.Http"] = "Microsoft.AspNet.WebApi.Core", |
|
["System.Web.Http.WebHost"] = "Microsoft.AspNet.WebApi.WebHost", |
|
["System.Web.Http.SelfHost"] = "Microsoft.AspNet.WebApi.SelfHost", |
|
["System.Net.Http.Formatting"] = "Microsoft.AspNet.WebApi.Client", |
|
["Microsoft.Practices.Unity"] = "Unity", |
|
["System.Data.SqlServerCe"] = "Microsoft.SqlServer.Compact", |
|
["Microsoft.Practices.ServiceLocation"] = "CommonServiceLocator", |
|
}; |
|
|
|
const string NetCoreRefPack = "Microsoft.NETCore.App.Ref"; |
|
|
|
// WPF/WinForms assemblies of .NET (Core) live in the WindowsDesktop ref pack, not on nuget. |
|
var windowsDesktopPrefixes = new[] { |
|
"PresentationCore", "PresentationFramework", "WindowsBase", "System.Xaml", |
|
"System.Windows.", "System.Drawing", "ReachFramework", "System.Printing", |
|
"UIAutomation", "Microsoft.VisualBasic.Forms", |
|
}; |
|
|
|
// Render the aggregate report of a sweep and exit; nothing is decompiled in this mode. |
|
if (args is ["--report", var ledgerPath, ..]) |
|
{ |
|
var outPath = args.Length > 2 ? args[2] : Path.ChangeExtension(ledgerPath, ".html"); |
|
RenderLedger(ledgerPath, outPath); |
|
Console.WriteLine($"report: {Path.GetFullPath(outPath)}"); |
|
return 0; |
|
} |
|
|
|
// Populates the cache without decompiling: the sweep is the slow part, and a corpus |
|
// only needs the assemblies on disk. |
|
var downloadOnly = args.Contains("--download-only"); |
|
// Generates a PDB per assembly and checks it, instead of decompiling type by type. The two are |
|
// alternatives rather than additions: a whole-assembly PDB is far more expensive than the type |
|
// sweep, and they answer different questions. |
|
var pdbMode = args.Contains("--pdb"); |
|
// Checks the PDB the assembly already ships with. Calibration, not a sweep. |
|
var pdbLint = args.Contains("--pdb-lint"); |
|
// Findings first hit by the assembly being decompiled. They get their reference context |
|
// once it is done, because the resolver keeps discovering references until then. |
|
var pendingContext = new List<string>(); |
|
var arguments = args |
|
.Where(a => !a.StartsWith("--")) |
|
.SelectMany(a => a.StartsWith('@') ? File.ReadAllLines(a[1..]) : new[] { a }) |
|
.Select(l => l.Trim()) |
|
.Where(l => l.Length > 0 && !l.StartsWith('#')) |
|
.ToList(); |
|
if (arguments.Count == 0) |
|
{ |
|
Console.Error.WriteLine("usage: nugetfuzz [--download-only|--pdb] <PackageId[@Version]>... | @packagelist.txt"); |
|
Console.Error.WriteLine(" nugetfuzz --pdb-lint <file.dll|dir>... | @corpus.txt"); |
|
Console.Error.WriteLine(" nugetfuzz --report <ledger.jsonl> [out.html]"); |
|
return 1; |
|
} |
|
|
|
if (pdbLint) |
|
{ |
|
foreach (var dll in ExpandDlls(arguments)) |
|
LintExistingPdb(dll); |
|
} |
|
else |
|
{ |
|
foreach (var spec in arguments) |
|
{ |
|
try |
|
{ |
|
await ProcessPackage(spec); |
|
} |
|
catch (Exception ex) when ( |
|
ex is InvalidOperationException && ex.Message.Contains("not found") |
|
|| ex is InvalidDataException) |
|
{ |
|
// Deleted/delisted package or corrupt nupkg on nuget.org - not a decompiler issue. |
|
Console.WriteLine($" skip {spec}: {ex.Message}"); |
|
} |
|
catch (Exception ex) |
|
{ |
|
Report(spec, "-", "-", ex); |
|
} |
|
} |
|
} |
|
|
|
Console.WriteLine(); |
|
if (pdbMode || pdbLint) |
|
Console.WriteLine($"=== {assemblyCount} assemblies, {pdbChecked} PDBs checked, {pdbSkipped} skipped, {failures.Count} distinct failures ({failures.Values.Sum(f => f.Count)} total) ==="); |
|
else |
|
Console.WriteLine($"=== {assemblyCount} assemblies, {typeCount} types decompiled ({charCount} chars), {refsResolved}/{refsTotal} refs resolved, {failures.Count} distinct failures ({failures.Values.Sum(f => f.Count)} total) ==="); |
|
foreach (var entry in failures.Values.OrderByDescending(f => f.Count)) |
|
Console.WriteLine($"{entry.Count,6}x {entry.Describe()}"); |
|
// A sweep runs this program once per package, so per-run findings are appended to a |
|
// shared ledger; `--report <ledger>` renders the aggregate. Without a ledger the run |
|
// reports only itself. |
|
var ledger = Environment.GetEnvironmentVariable("NUGETFUZZ_LEDGER"); |
|
if (downloadOnly) |
|
{ |
|
// Nothing was decompiled, so there are no findings to report on. |
|
} |
|
else if (ledger != null) |
|
{ |
|
AppendToLedger(ledger, failures.Values, assemblyCount, typeCount, refsResolved, refsTotal); |
|
Console.WriteLine($"ledger: {Path.GetFullPath(ledger)}"); |
|
} |
|
else |
|
{ |
|
var htmlPath = Path.GetFullPath(Environment.GetEnvironmentVariable("NUGETFUZZ_HTML") ?? "nugetfuzz-report.html"); |
|
WriteHtmlReport(htmlPath, failures.Values.ToList(), assemblyCount, typeCount, refsResolved, refsTotal, dumpDir); |
|
Console.WriteLine($"report: {htmlPath}"); |
|
} |
|
return failures.Count == 0 ? 0 : 1; |
|
|
|
async Task ProcessPackage(string spec) |
|
{ |
|
var parts = spec.Split('@'); |
|
var id = parts[0]; |
|
var version = parts.Length > 1 ? NuGetVersion.Parse(parts[1]) : await ResolveVersion(id, null); |
|
if (version == null) |
|
{ |
|
Console.WriteLine($"=== {id}: no versions found, skipping ==="); |
|
return; |
|
} |
|
Console.WriteLine($"=== {id} {version} ==="); |
|
var dir = await GetPackage(id, version); |
|
|
|
var matchTarget = installedTfm; |
|
var pick = PickLib(dir, matchTarget); |
|
if (pick == null) |
|
{ |
|
matchTarget = net48; |
|
pick = PickLib(dir, matchTarget); |
|
} |
|
if (pick == null) |
|
{ |
|
Console.WriteLine(" no compatible lib assemblies, skipping"); |
|
return; |
|
} |
|
var (libFw, libDir) = pick.Value; |
|
Console.WriteLine($" lib: {libFw.GetShortFolderName()}"); |
|
|
|
var searchDirs = await CollectDependencies(dir, matchTarget, id); |
|
searchDirs.Insert(0, libDir); |
|
if (downloadOnly) |
|
{ |
|
// The package and its dependency closure are in the cache now, which is all a |
|
// corpus needs; decompiling every type is what the sweep is for. |
|
Console.WriteLine($" cached: {libDir}"); |
|
return; |
|
} |
|
// The installed runtime dir is a last-resort fallback only: Microsoft.NETCore.App |
|
// ships stub facades (e.g. WindowsBase.dll without System.Windows.Point) that must |
|
// not shadow the real assemblies from ref packs or dependency packages. |
|
string? fallbackDir = null; |
|
if (libFw.Framework == FrameworkConstants.FrameworkIdentifiers.Net) |
|
searchDirs.AddRange(await GetNetFxRefDirs(libFw)); |
|
else |
|
fallbackDir = Path.GetDirectoryName(typeof(object).Assembly.Location); |
|
|
|
foreach (var dll in Directory.GetFiles(libDir, "*.dll").OrderBy(f => f)) |
|
await DecompileAssembly(id, dll, searchDirs, matchTarget, fallbackDir); |
|
} |
|
|
|
// Walks the dependency closure breadth-first, downloading each package once and |
|
// collecting the lib dir that best matches the root package's target framework. |
|
async Task<List<string>> CollectDependencies(string rootDir, NuGetFramework matchTarget, string rootId) |
|
{ |
|
var searchDirs = new List<string>(); |
|
var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { rootId }; |
|
var queue = new Queue<string>(); |
|
queue.Enqueue(rootDir); |
|
while (queue.Count > 0) |
|
{ |
|
var dir = queue.Dequeue(); |
|
var nuspecPath = Directory.GetFiles(dir, "*.nuspec").FirstOrDefault(); |
|
if (nuspecPath == null) |
|
continue; |
|
var groups = new NuspecReader(nuspecPath).GetDependencyGroups().ToList(); |
|
var nearestGroupFw = reducer.GetNearest(matchTarget, groups.Select(g => g.TargetFramework)); |
|
var group = groups.FirstOrDefault(g => g.TargetFramework.Equals(nearestGroupFw)); |
|
if (group == null) |
|
continue; |
|
foreach (var dep in group.Packages) |
|
{ |
|
if (!visited.Add(dep.Id)) |
|
continue; |
|
try |
|
{ |
|
var depVersion = await ResolveVersion(dep.Id, dep.VersionRange); |
|
if (depVersion == null) |
|
continue; |
|
var depDir = await GetPackage(dep.Id, depVersion); |
|
var depPick = PickLib(depDir, matchTarget); |
|
if (depPick != null) |
|
searchDirs.Add(depPick.Value.dir); |
|
queue.Enqueue(depDir); |
|
} |
|
catch (Exception ex) |
|
{ |
|
Console.WriteLine($" ! dep {dep.Id}: {ex.Message}"); |
|
} |
|
} |
|
} |
|
return searchDirs; |
|
} |
|
|
|
// Picks the lib/<tfm> directory nearest to the given target framework. |
|
// Old-style packages with DLLs directly under lib/ are treated as classic .NET Framework. |
|
(NuGetFramework fw, string dir)? PickLib(string pkgDir, NuGetFramework target) |
|
{ |
|
var libRoot = Path.Combine(pkgDir, "lib"); |
|
if (!Directory.Exists(libRoot)) |
|
return null; |
|
var map = new Dictionary<NuGetFramework, string>(); |
|
foreach (var d in Directory.GetDirectories(libRoot)) |
|
{ |
|
NuGetFramework fw; |
|
try |
|
{ |
|
fw = NuGetFramework.ParseFolder(Path.GetFileName(d)); |
|
} |
|
catch |
|
{ |
|
continue; |
|
} |
|
if (fw.IsSpecificFramework && Directory.GetFiles(d, "*.dll").Length > 0) |
|
map[fw] = d; |
|
} |
|
var nearest = reducer.GetNearest(target, map.Keys); |
|
if (nearest != null) |
|
return (nearest, map[nearest]); |
|
if (target.Framework == FrameworkConstants.FrameworkIdentifiers.Net |
|
&& Directory.GetFiles(libRoot, "*.dll").Length > 0) |
|
{ |
|
return (target, libRoot); |
|
} |
|
return null; |
|
} |
|
|
|
// Classic .NET Framework has no reference assemblies on this machine; fetch the |
|
// matching Microsoft.NETFramework.ReferenceAssemblies package instead. |
|
async Task<List<string>> GetNetFxRefDirs(NuGetFramework libFw) |
|
{ |
|
var shortName = libFw.GetShortFolderName(); |
|
if (!Regex.IsMatch(shortName, "^net[0-9]+$")) |
|
shortName = "net48"; |
|
// Reference-assembly packs exist only for these TFMs; map anything else (net30, |
|
// net401, ...) to the smallest pack that is a superset of the requested framework. |
|
string[] knownPacks = ["net20", "net35", "net40", "net45", "net451", "net452", "net46", "net461", "net462", "net47", "net471", "net472", "net48", "net481"]; |
|
if (!knownPacks.Contains(shortName)) |
|
{ |
|
static Version DigitsVersion(string tfm) => Version.Parse(string.Join('.', tfm[3..].ToCharArray())); |
|
var requested = DigitsVersion(shortName); |
|
shortName = knownPacks.FirstOrDefault(k => DigitsVersion(k) >= requested) ?? "net48"; |
|
} |
|
var id = "Microsoft.NETFramework.ReferenceAssemblies." + shortName; |
|
var version = await ResolveVersion(id, null) |
|
?? throw new InvalidOperationException($"cannot resolve {id}"); |
|
var dir = await GetPackage(id, version); |
|
var fxRoot = Directory.GetDirectories(Path.Combine(dir, "build", ".NETFramework")).Single(); |
|
var dirs = new List<string> { fxRoot }; |
|
var facades = Path.Combine(fxRoot, "Facades"); |
|
if (Directory.Exists(facades)) |
|
dirs.Add(facades); |
|
return dirs; |
|
} |
|
|
|
async Task<NuGetVersion?> ResolveVersion(string id, VersionRange? range) |
|
{ |
|
var versions = await GetVersions(id) |
|
?? throw new InvalidOperationException($"package {id} not found"); |
|
if (range != null) |
|
return range.FindBestMatch(versions) ?? versions.LastOrDefault(); |
|
return versions.LastOrDefault(v => !v.IsPrerelease) ?? versions.LastOrDefault(); |
|
} |
|
|
|
// nuget.org stalls or resets a connection now and then. Without a retry such a flake |
|
// travels all the way out as an unhandled exception, which the report files as a |
|
// decompiler [EXCEPTION] - the one bucket that must contain nothing but real crashes - |
|
// and the package is skipped without a single type being decompiled. A 404 is an |
|
// answer, not a flake, so it is passed straight through to the caller. |
|
async Task<T> WithRetry<T>(Func<Task<T>> request) |
|
{ |
|
for (int attempt = 1; ; attempt++) |
|
{ |
|
try |
|
{ |
|
return await request(); |
|
} |
|
catch (Exception ex) when (attempt < 3 && IsTransient(ex)) |
|
{ |
|
Console.WriteLine($" ! transient http failure ({ex.GetType().Name}), retry {attempt}/2"); |
|
await Task.Delay(TimeSpan.FromSeconds(2 * attempt)); |
|
} |
|
} |
|
|
|
static bool IsTransient(Exception ex) => ex switch { |
|
// HttpClient.Timeout surfaces as a cancellation, not as a timeout |
|
TaskCanceledException or IOException => true, |
|
// no status code at all means the request never got an answer: DNS, reset, TLS |
|
HttpRequestException { StatusCode: null } => true, |
|
HttpRequestException { StatusCode: var status } => |
|
status == System.Net.HttpStatusCode.TooManyRequests || (int)status! >= 500, |
|
_ => false, |
|
}; |
|
} |
|
|
|
async Task<List<NuGetVersion>?> GetVersions(string id) |
|
{ |
|
var key = id.ToLowerInvariant(); |
|
if (noSuchPackage.Contains(key)) |
|
return null; |
|
if (versionCache.TryGetValue(key, out var cached)) |
|
return cached; |
|
try |
|
{ |
|
var index = await WithRetry(() => http.GetFromJsonAsync<VersionIndex>( |
|
$"https://api.nuget.org/v3-flatcontainer/{key}/index.json")); |
|
var versions = index!.versions.Select(NuGetVersion.Parse).ToList(); |
|
versionCache[key] = versions; |
|
return versions; |
|
} |
|
catch (HttpRequestException) |
|
{ |
|
noSuchPackage.Add(key); |
|
return null; |
|
} |
|
} |
|
|
|
// Some packages reference assemblies they never declare as dependencies (e.g. |
|
// itextsharp 5.5.13.6 -> itext.commons). Best effort: for any assembly reference |
|
// not satisfiable from the current search dirs, try the same-named nuget package, |
|
// preferring the package version that matches the assembly version. |
|
// Called by LoggingResolver whenever a reference resolves nowhere - main-module refs |
|
// and transitive refs alike (e.g. a fetched Microsoft.AspNet.Mvc needing WebPages). |
|
// Tries framework ref packs and same-named/aliased nuget packages; returns true when |
|
// the dll is available in `dirs` afterwards. |
|
async Task<bool> TryFetchMissingRef(IAssemblyReference reference, Version? coreVersion, NuGetFramework matchTarget, List<string> dirs) |
|
{ |
|
bool Satisfied() |
|
{ |
|
lock (dirs) |
|
{ |
|
return dirs.Any(d => File.Exists(Path.Combine(d, reference.Name + ".dll"))); |
|
} |
|
} |
|
if (coreVersion != null |
|
&& windowsDesktopPrefixes.Any(p => reference.Name.StartsWith(p, StringComparison.Ordinal))) |
|
{ |
|
await AddRefPack("Microsoft.WindowsDesktop.App.Ref", coreVersion, dirs); |
|
if (Satisfied()) |
|
return true; |
|
} |
|
if (coreVersion != null && reference.Name.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal)) |
|
{ |
|
await AddRefPack("Microsoft.AspNetCore.App.Ref", coreVersion, dirs); |
|
if (Satisfied()) |
|
return true; |
|
} |
|
const string EntLib = "Microsoft.Practices.EnterpriseLibrary."; |
|
var packageId = assemblyPackageAlias.GetValueOrDefault(reference.Name) |
|
?? (reference.Name.StartsWith(EntLib, StringComparison.Ordinal) |
|
? "EnterpriseLibrary." + reference.Name[EntLib.Length..] |
|
: reference.Name); |
|
var versions = await GetVersions(packageId); |
|
if (versions == null || versions.Count == 0) |
|
return false; |
|
var version = versions.FirstOrDefault(v => !v.IsPrerelease && v.Version == reference.Version) |
|
?? versions.LastOrDefault(v => !v.IsPrerelease && v.Major == reference.Version?.Major) |
|
?? versions.LastOrDefault(v => !v.IsPrerelease) ?? versions[^1]; |
|
try |
|
{ |
|
var pkgDir = await GetPackage(packageId, version); |
|
var pick = PickLib(pkgDir, matchTarget); |
|
if (pick != null) |
|
{ |
|
Console.WriteLine($" + undeclared dependency {reference.Name} -> {packageId} {version} ({pick.Value.fw.GetShortFolderName()})"); |
|
lock (dirs) |
|
{ |
|
dirs.Add(pick.Value.dir); |
|
} |
|
} |
|
} |
|
catch (Exception ex) |
|
{ |
|
Console.WriteLine($" ! undeclared dependency {reference.Name}: {ex.Message}"); |
|
} |
|
return Satisfied(); |
|
} |
|
|
|
// Adds the ref/<tfm> directory of a framework ref pack (WindowsDesktop, AspNetCore) |
|
// matching the module's .NET version. No-op if already added or unavailable. |
|
async Task AddRefPack(string packId, Version coreVersion, List<string> searchDirs) |
|
{ |
|
var versions = await GetVersions(packId); |
|
if (versions == null) |
|
return; |
|
// Never fall back to "newest available": the ref packs only go back to 3.0, so a |
|
// netcoreapp1.x/2.x assembly would silently bind against a current (or prerelease) |
|
// BCL and decompile as a wall of "Unknown result type". Better to add nothing and |
|
// say so than to resolve against the wrong framework. |
|
var version = versions.LastOrDefault(v => !v.IsPrerelease && v.Major == coreVersion.Major && v.Minor == coreVersion.Minor) |
|
?? versions.LastOrDefault(v => !v.IsPrerelease && v.Major <= coreVersion.Major); |
|
if (version == null) |
|
{ |
|
Console.WriteLine($" ! ref pack {packId}: nothing published for {coreVersion}, references may bind to the wrong framework"); |
|
return; |
|
} |
|
try |
|
{ |
|
var dir = await GetPackage(packId, version); |
|
var refRoot = Path.Combine(dir, "ref"); |
|
var refDir = Directory.Exists(refRoot) ? Directory.GetDirectories(refRoot).FirstOrDefault() : null; |
|
if (refDir != null && !searchDirs.Contains(refDir)) |
|
{ |
|
Console.WriteLine($" + ref pack {packId} {version}"); |
|
// Microsoft.NETCore.App.Ref ships stub facades -- its WindowsBase.dll is 15 KB and |
|
// has no DependencyObject -- which shadow the real assemblies and collapse whole |
|
// type hierarchies to Unknown. Keep it behind every other ref pack. |
|
int netCorePack = searchDirs.FindIndex( |
|
d => d.Contains(NetCoreRefPack, StringComparison.OrdinalIgnoreCase)); |
|
if (netCorePack >= 0 && packId != NetCoreRefPack) |
|
searchDirs.Insert(netCorePack, refDir); |
|
else |
|
searchDirs.Add(refDir); |
|
} |
|
} |
|
catch (Exception ex) |
|
{ |
|
Console.WriteLine($" ! ref pack {packId}: {ex.Message}"); |
|
} |
|
} |
|
|
|
async Task<string> GetPackage(string id, NuGetVersion version) |
|
{ |
|
var idLower = id.ToLowerInvariant(); |
|
var v = version.ToNormalizedString().ToLowerInvariant(); |
|
// The machine-wide NuGet cache is checked first: every `dotnet restore` on this box |
|
// already extracts packages there in the same layout, so anything a build has pulled |
|
// in never gets downloaded a second time. Our own cache stays the write target - |
|
// nothing here ever writes into the shared one. |
|
var globalDir = Path.Combine(globalPackagesRoot, idLower, v); |
|
if (Directory.Exists(globalDir)) |
|
return globalDir; |
|
var dir = Path.Combine(cacheRoot, idLower, v); |
|
if (!Directory.Exists(dir)) |
|
{ |
|
Console.WriteLine($" downloading {id} {v}"); |
|
var bytes = await WithRetry(() => http.GetByteArrayAsync( |
|
$"https://api.nuget.org/v3-flatcontainer/{idLower}/{v}/{idLower}.{v}.nupkg")); |
|
var tmp = dir + ".tmp"; |
|
if (Directory.Exists(tmp)) |
|
Directory.Delete(tmp, true); |
|
ZipFile.ExtractToDirectory(new MemoryStream(bytes), tmp); |
|
Directory.Move(tmp, dir); |
|
} |
|
return dir; |
|
} |
|
|
|
async Task DecompileAssembly(string pkg, string dllPath, List<string> searchDirs, NuGetFramework matchTarget, string? fallbackDir) |
|
{ |
|
// An assembly that bails out before reporting its resolutions leaves findings behind that |
|
// never received a context. Dropping them here keeps the next assembly from stamping its |
|
// own references onto them, which would name the wrong reference set for the finding. |
|
pendingContext.Clear(); |
|
var name = Path.GetFileName(dllPath); |
|
PEFile module; |
|
try |
|
{ |
|
module = new PEFile(dllPath); |
|
} |
|
catch (Exception ex) when (ex is BadImageFormatException or MetadataFileNotSupportedException) |
|
{ |
|
Console.WriteLine($" skip {name}: not a managed assembly"); |
|
return; |
|
} |
|
using (module) |
|
{ |
|
// The file name alone is ambiguous across a sweep: the same simple name ships in many |
|
// packages and many TFM folders. Identify findings by full assembly name plus path. |
|
name = $"{module.FullName} ({dllPath})"; |
|
// ".NETCoreApp,Version=v5.0" -> 5.0; null for .NET Framework / netstandard modules. |
|
Version? coreVersion = null; |
|
var tfmId = module.DetectTargetFrameworkId(); |
|
var versionIndex = tfmId.IndexOf("Version=v", StringComparison.Ordinal); |
|
if (tfmId.StartsWith(".NETCoreApp", StringComparison.Ordinal) && versionIndex >= 0) |
|
coreVersion = Version.Parse(tfmId[(versionIndex + 9)..]); |
|
|
|
var resolver = new UniversalAssemblyResolver(dllPath, throwOnError: false, tfmId); |
|
var orderedDirs = new List<string>(searchDirs); |
|
// Bind to the ref packs of the framework the assembly was built for. Otherwise the |
|
// references still resolve -- against whatever shared runtime happens to be |
|
// installed -- and every type that moved or was removed since then decompiles as |
|
// "Unknown result type", which is indistinguishable from a decompiler bug. |
|
// The desktop and web packs have to be seeded here rather than left to |
|
// TryFetchMissingRef, which only runs when a reference resolves nowhere: the |
|
// facades described in AddRefPack satisfy those references, so it never fires. |
|
if (coreVersion != null) |
|
{ |
|
var refNames = module.AssemblyReferences.Select(r => r.Name).ToList(); |
|
if (refNames.Any(n => windowsDesktopPrefixes.Any(p => n.StartsWith(p, StringComparison.Ordinal)))) |
|
await AddRefPack("Microsoft.WindowsDesktop.App.Ref", coreVersion, orderedDirs); |
|
if (refNames.Any(n => n.StartsWith("Microsoft.AspNetCore", StringComparison.Ordinal))) |
|
await AddRefPack("Microsoft.AspNetCore.App.Ref", coreVersion, orderedDirs); |
|
await AddRefPack(NetCoreRefPack, coreVersion, orderedDirs); |
|
} |
|
// Last-resort only, for the same reason the ref packs are ordered as they are. |
|
if (fallbackDir != null) |
|
orderedDirs.Add(fallbackDir); |
|
foreach (var d in orderedDirs) |
|
resolver.AddSearchDirectory(d); |
|
var fetchGate = new object(); |
|
var logResolver = new LoggingResolver(resolver, orderedDirs, reference => { |
|
lock (fetchGate) |
|
{ |
|
return TryFetchMissingRef(reference, coreVersion, matchTarget, orderedDirs) |
|
.GetAwaiter().GetResult(); |
|
} |
|
}); |
|
CSharpDecompiler decompiler; |
|
try |
|
{ |
|
decompiler = new CSharpDecompiler(module, logResolver, new DecompilerSettings()); |
|
} |
|
catch (Exception ex) |
|
{ |
|
Report(pkg, name, "<typesystem>", ex); |
|
return; |
|
} |
|
Console.WriteLine($" {name}"); |
|
assemblyCount++; |
|
if (pdbMode) |
|
{ |
|
CheckGeneratedPdb(pkg, name, module, decompiler, dllPath, orderedDirs); |
|
ReportResolutions(logResolver, dllPath, orderedDirs); |
|
return; |
|
} |
|
foreach (var type in decompiler.TypeSystem.MainModule.TopLevelTypeDefinitions.ToList()) |
|
{ |
|
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); |
|
decompiler.CancellationToken = cts.Token; |
|
try |
|
{ |
|
var tree = decompiler.DecompileType(type.FullTypeName); |
|
var code = SyntaxTreeToString(tree); |
|
typeCount++; |
|
charCount += code.Length; |
|
// Compiler-generated types (<Module>, <PrivateImplementationDetails>, |
|
// VB$AnonymousType_*, ...) are still decompiled to shake out edge cases, |
|
// but empty output is normal for them ('<' and '$' match the decompiler's |
|
// own generated-name detection in SRMExtensions.IsGeneratedName). |
|
bool generatedType = type.Name.StartsWith('<') || type.Name.Contains('$'); |
|
if (string.IsNullOrWhiteSpace(code) && !generatedType) |
|
Report(pkg, name, type.FullTypeName.ToString(), new InvalidDataException("empty decompilation output")); |
|
else if (dumpDir != null) |
|
File.WriteAllText(Path.Combine(dumpDir, SanitizeFileName($"{pkg}.{type.FullTypeName}.cs")), code); |
|
// An identifier may only contain letters, digits and '_' (the rule |
|
// EscapeInvalidIdentifiers.IsValid applies for project output). Any other name in |
|
// the tree is a compiler-generated entity the decompiler failed to fold away, and |
|
// the output does not compile. Inside a generated type everything is mangled by |
|
// definition, so only user-written types are checked. |
|
if (!generatedType) |
|
{ |
|
foreach (var leak in tree.DescendantsAndSelf.OfType<Identifier>() |
|
.Select(i => i.Name) |
|
.Where(n => !n.All(ch => char.IsLetterOrDigit(ch) || ch == '_')) |
|
.Distinct()) |
|
{ |
|
Report(pkg, name, type.FullTypeName.ToString(), new LeakedName(leak)); |
|
} |
|
} |
|
// ILFunction warnings (unknown result types, stack type mismatches, invalid IL) |
|
// surface in the output as "//IL_xxxx: <message>" comments. |
|
foreach (var warning in Regex.Matches(code, @"//IL_[0-9a-fA-F]+: (.*)") |
|
.Select(m => m.Groups[1].Value.Trim()).Distinct()) |
|
{ |
|
Report(pkg, name, type.FullTypeName.ToString(), new DecompilerWarning(warning)); |
|
} |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
Report(pkg, name, type.FullTypeName.ToString(), new TimeoutException("decompilation timed out (60s)")); |
|
} |
|
catch (Exception ex) |
|
{ |
|
Report(pkg, name, type.FullTypeName.ToString(), ex); |
|
} |
|
} |
|
ReportResolutions(logResolver, dllPath, orderedDirs); |
|
} |
|
} |
|
|
|
string SyntaxTreeToString(SyntaxTree syntaxTree) |
|
{ |
|
var w = new StringWriter(); |
|
syntaxTree.AcceptVisitor(new CSharpOutputVisitor(w, formatting)); |
|
return w.ToString(); |
|
} |
|
|
|
void ReportResolutions(LoggingResolver logResolver, string dllPath, List<string> orderedDirs) |
|
{ |
|
var resolutions = logResolver.Resolutions; |
|
var unresolved = resolutions.Where(kv => kv.Value == null).Select(kv => kv.Key).OrderBy(k => k).ToList(); |
|
// What it takes to reproduce a finding by hand: the assembly it came from and the |
|
// references it was decompiled against, which are what the report is read for once |
|
// a warning turns out to be a reference problem rather than a decompiler defect. |
|
var context = new StringBuilder($"assembly: {dllPath}"); |
|
foreach (var dir in orderedDirs) |
|
context.Append($"{Environment.NewLine} -r {dir}"); |
|
foreach (var (refName, refPath) in resolutions.OrderBy(kv => kv.Key, StringComparer.Ordinal)) |
|
context.Append($"{Environment.NewLine} {refName} -> {refPath ?? "NOT FOUND"}"); |
|
foreach (var key in pendingContext) |
|
failures[key] = failures[key] with { Context = context.ToString() }; |
|
pendingContext.Clear(); |
|
refsTotal += resolutions.Count; |
|
refsResolved += resolutions.Count - unresolved.Count; |
|
Console.WriteLine($" refs: {resolutions.Count - unresolved.Count}/{resolutions.Count} resolved"); |
|
if (verbose) |
|
{ |
|
foreach (var kv in resolutions.OrderBy(kv => kv.Key)) |
|
Console.WriteLine($" {kv.Key} -> {kv.Value ?? "NOT FOUND"}"); |
|
} |
|
else |
|
{ |
|
foreach (var u in unresolved) |
|
Console.WriteLine($" ! unresolved: {u}"); |
|
} |
|
} |
|
|
|
// --------------------------------------------------------------------------------------------- |
|
// PDB verification. Two independent checks over the same PDB: the Cecil round-trip answers "can |
|
// the consumer ILLink uses read this at all", which is how #2823 surfaced; the lint answers "is |
|
// what it reads true of the assembly", which a crash-free but wrong PDB still fails. |
|
// --------------------------------------------------------------------------------------------- |
|
|
|
void CheckGeneratedPdb(string pkg, string asm, PEFile module, CSharpDecompiler decompiler, |
|
string dllPath, List<string> searchDirs) |
|
{ |
|
// The writer takes the PDB id from the PE's CodeView debug directory entry, and a consumer |
|
// rejects a PDB whose id does not match that entry, so without one there is nothing to check |
|
// - the same reason ilspycmd refuses these assemblies. |
|
if (!PortablePdbWriter.HasCodeViewDebugDirectoryEntry(module)) |
|
{ |
|
Console.WriteLine(" skip: no CodeView debug directory entry"); |
|
pdbSkipped++; |
|
return; |
|
} |
|
var pdbStream = new MemoryStream(); |
|
var asserts = new List<string>(); |
|
using (var cts = new CancellationTokenSource(TimeSpan.FromMinutes(10))) |
|
{ |
|
decompiler.CancellationToken = cts.Token; |
|
Trace.Listeners.Clear(); |
|
Trace.Listeners.Add(new CollectAsserts(asserts)); |
|
try |
|
{ |
|
new PortablePdbWriter { NoLogo = true } |
|
.WritePdb(module, decompiler, new DecompilerSettings(), pdbStream); |
|
} |
|
catch (OperationCanceledException) |
|
{ |
|
Report(pkg, asm, "<pdb>", new TimeoutException("PDB generation timed out (10min)")); |
|
return; |
|
} |
|
catch (Exception ex) |
|
{ |
|
Report(pkg, asm, "<pdb>", ex); |
|
return; |
|
} |
|
finally |
|
{ |
|
Trace.Listeners.Clear(); |
|
Trace.Listeners.Add(new ThrowOnAssert()); |
|
} |
|
} |
|
// Metadata tokens in an assertion message vary per method; without normalising them one |
|
// defect would fill the ledger with a finding per method it fired on. |
|
foreach (var message in asserts.Select(m => Regex.Replace(m, @"\b[0-9A-Fa-f]{8}\b", "<token>")).Distinct()) |
|
Report(pkg, asm, "<pdb>", new AssertionFailedException(message)); |
|
|
|
pdbChecked++; |
|
var bodies = CollectBodyFacts(dllPath, searchDirs); |
|
CecilRoundTrip(pkg, asm, dllPath, pdbStream, searchDirs); |
|
pdbStream.Position = 0; |
|
using var provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream); |
|
LintPdb(pkg, asm, module.Metadata, provider.GetMetadataReader(), bodies, EntryPoint(module.Reader)); |
|
} |
|
|
|
// Runs the same two checks against the PDB an assembly already ships with. This is how the lint |
|
// is calibrated: a PDB the C# compiler wrote must produce no findings at all, so anything |
|
// reported here is a defect in the lint rather than in ILSpy. |
|
void LintExistingPdb(string dllPath) |
|
{ |
|
using var peStream = File.OpenRead(dllPath); |
|
PEReader peReader; |
|
try |
|
{ |
|
peReader = new PEReader(peStream); |
|
if (!peReader.HasMetadata) |
|
return; |
|
} |
|
catch (BadImageFormatException) |
|
{ |
|
return; |
|
} |
|
using (peReader) |
|
{ |
|
var asm = $"{peReader.GetMetadataReader().GetFullAssemblyName()} ({dllPath})"; |
|
// A PDB next to the assembly if there is one, otherwise the one embedded in the PE. |
|
MemoryStream? pdbStream = null; |
|
MetadataReaderProvider? provider = null; |
|
var pdbPath = Path.ChangeExtension(dllPath, ".pdb"); |
|
try |
|
{ |
|
if (File.Exists(pdbPath)) |
|
{ |
|
var bytes = File.ReadAllBytes(pdbPath); |
|
// "BSJB": a Windows PDB is a different format the portable reader cannot open. |
|
if (bytes.Length < 4 || BitConverter.ToUInt32(bytes, 0) != 0x424A5342) |
|
{ |
|
pdbSkipped++; |
|
return; |
|
} |
|
pdbStream = new MemoryStream(bytes); |
|
provider = MetadataReaderProvider.FromPortablePdbStream(pdbStream, MetadataStreamOptions.LeaveOpen); |
|
} |
|
else |
|
{ |
|
var embedded = peReader.ReadDebugDirectory() |
|
.FirstOrDefault(e => e.Type == DebugDirectoryEntryType.EmbeddedPortablePdb); |
|
if (embedded.Type != DebugDirectoryEntryType.EmbeddedPortablePdb) |
|
return; |
|
provider = peReader.ReadEmbeddedPortablePdbDebugDirectoryData(embedded); |
|
} |
|
} |
|
catch (Exception ex) when (ex is IOException or BadImageFormatException) |
|
{ |
|
pdbSkipped++; |
|
return; |
|
} |
|
using (provider) |
|
using (pdbStream) |
|
{ |
|
Console.WriteLine($" {asm}"); |
|
assemblyCount++; |
|
pdbChecked++; |
|
var bodies = CollectBodyFacts(dllPath, null); |
|
CecilRoundTrip("-", asm, dllPath, pdbStream, null); |
|
LintPdb("-", asm, peReader.GetMetadataReader(), provider.GetMetadataReader(), bodies, |
|
EntryPoint(peReader)); |
|
} |
|
} |
|
} |
|
|
|
Cecil.ReaderParameters CecilParameters(List<string>? searchDirs, Stream? symbols, bool readSymbols) |
|
{ |
|
var resolver = new Cecil.DefaultAssemblyResolver(); |
|
foreach (var dir in searchDirs ?? []) |
|
resolver.AddSearchDirectory(dir); |
|
var parameters = new Cecil.ReaderParameters { AssemblyResolver = resolver }; |
|
if (!readSymbols) |
|
return parameters; |
|
parameters.ReadSymbols = true; |
|
if (symbols != null) |
|
{ |
|
symbols.Position = 0; |
|
parameters.SymbolReaderProvider = new CecilCil.PortablePdbReaderProvider(); |
|
parameters.SymbolStream = symbols; |
|
} |
|
else |
|
{ |
|
parameters.SymbolReaderProvider = new CecilCil.EmbeddedPortablePdbReaderProvider(); |
|
} |
|
return parameters; |
|
} |
|
|
|
// Parses every method body without symbols, so the lint has the assembly's own truth about |
|
// instruction boundaries, exception handlers and local counts to check the PDB against - and |
|
// still has it for the very methods whose debug information breaks the round-trip below. |
|
Dictionary<int, BodyFacts> CollectBodyFacts(string dllPath, List<string>? searchDirs) |
|
{ |
|
var facts = new Dictionary<int, BodyFacts>(); |
|
try |
|
{ |
|
using var assembly = Cecil.AssemblyDefinition.ReadAssembly(dllPath, CecilParameters(searchDirs, null, readSymbols: false)); |
|
foreach (var type in assembly.MainModule.GetTypes()) |
|
{ |
|
foreach (var method in type.Methods) |
|
{ |
|
if (!method.HasBody) |
|
continue; |
|
try |
|
{ |
|
var body = method.Body; |
|
facts[(int)method.MetadataToken.RID] = new BodyFacts( |
|
body.CodeSize, |
|
body.Instructions.Select(i => i.Offset).ToHashSet(), |
|
body.ExceptionHandlers |
|
.Where(h => h.HandlerType == CecilCil.ExceptionHandlerType.Catch) |
|
.Select(h => h.HandlerStart.Offset).ToHashSet(), |
|
body.Variables.Count); |
|
} |
|
catch (Exception) |
|
{ |
|
// A body that will not parse without symbols says nothing about the PDB; the |
|
// lint simply has no facts to check that one method against. |
|
} |
|
} |
|
} |
|
} |
|
catch (Exception ex) |
|
{ |
|
Console.WriteLine($" ! body scan failed: {ex.GetType().Name}: {FirstLine(ex.Message)}"); |
|
} |
|
return facts; |
|
} |
|
|
|
// Reads every method body through the PDB, which is what makes Cecil decode the custom debug |
|
// information attached to it. This is the path that fails in #2823 and inside ILLink. |
|
void CecilRoundTrip(string pkg, string asm, string dllPath, Stream? pdbStream, List<string>? searchDirs) |
|
{ |
|
Cecil.AssemblyDefinition assembly; |
|
try |
|
{ |
|
assembly = Cecil.AssemblyDefinition.ReadAssembly(dllPath, CecilParameters(searchDirs, pdbStream, readSymbols: true)); |
|
} |
|
catch (CecilCil.SymbolsNotMatchingException) |
|
{ |
|
Report(pkg, asm, "<pdb>", new PdbFinding( |
|
"[MATCH] the PDB does not match the assembly's CodeView debug directory entry")); |
|
return; |
|
} |
|
catch (Exception ex) |
|
{ |
|
Report(pkg, asm, "<pdb>", new PdbFinding( |
|
$"[CECIL] {ex.GetType().Name} opening the assembly with the PDB: {FirstLine(ex.Message)}")); |
|
return; |
|
} |
|
using (assembly) |
|
{ |
|
foreach (var type in assembly.MainModule.GetTypes()) |
|
{ |
|
foreach (var method in type.Methods) |
|
{ |
|
if (!method.HasBody) |
|
continue; |
|
try |
|
{ |
|
_ = method.Body; |
|
_ = method.DebugInformation.SequencePoints; |
|
} |
|
catch (Cecil.AssemblyResolutionException) |
|
{ |
|
// A reference the corpus does not contain, not a defect in the PDB. |
|
} |
|
catch (Exception ex) |
|
{ |
|
// The Cecil frame separates distinct decode failures; the method name goes |
|
// into the location, so the message stays the same for all of them. |
|
var frame = (ex.StackTrace ?? "").Split('\n').Select(l => l.Trim()) |
|
.FirstOrDefault(l => l.Contains("Mono.Cecil")) ?? ""; |
|
Report(pkg, asm, method.FullName, new PdbFinding( |
|
$"[CECIL] {ex.GetType().Name} reading a method body through the PDB @ {frame}")); |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
// Structural lint: everything the PDB claims has to be true of the assembly it describes. The |
|
// category is a prefix on the message, so one finding kind covers all of them in the report. |
|
void LintPdb(string pkg, string asm, MetadataReader pe, MetadataReader pdb, Dictionary<int, BodyFacts> bodies, |
|
MethodDefinitionHandle entryPoint) |
|
{ |
|
var sourceLines = new Dictionary<int, string[]?>(); |
|
|
|
string MethodName(int rid) |
|
{ |
|
if (rid <= 0 || rid > pe.MethodDefinitions.Count) |
|
return "<pdb>"; |
|
var method = pe.GetMethodDefinition(MetadataTokens.MethodDefinitionHandle(rid)); |
|
var type = pe.GetTypeDefinition(method.GetDeclaringType()); |
|
var ns = pe.GetString(type.Namespace); |
|
return $"{(ns.Length > 0 ? ns + "." : "")}{pe.GetString(type.Name)}.{pe.GetString(method.Name)}"; |
|
} |
|
|
|
// One report per method per defect: a method with hundreds of sequence points would |
|
// otherwise contribute hundreds of hits for a single mistake. |
|
void Once(HashSet<string> seen, int rid, string message) |
|
{ |
|
if (seen.Add(message)) |
|
Report(pkg, asm, MethodName(rid), new PdbFinding(message)); |
|
} |
|
|
|
// The embedded source, as lines, for checking that sequence points point at real text. |
|
string[]? EmbeddedLines(DocumentHandle handle) |
|
{ |
|
int key = MetadataTokens.GetRowNumber(handle); |
|
if (sourceLines.TryGetValue(key, out var cached)) |
|
return cached; |
|
string[]? lines = null; |
|
foreach (var infoHandle in pdb.GetCustomDebugInformation(handle)) |
|
{ |
|
var info = pdb.GetCustomDebugInformation(infoHandle); |
|
if (pdb.GetGuid(info.Kind) != KnownGuids.EmbeddedSource) |
|
continue; |
|
var blob = pdb.GetBlobBytes(info.Value); |
|
if (blob.Length < 4) |
|
break; |
|
// int32 uncompressed size, then the bytes - deflated when the size is non-zero. |
|
int uncompressedSize = BitConverter.ToInt32(blob, 0); |
|
using var raw = new MemoryStream(blob, 4, blob.Length - 4); |
|
using Stream content = uncompressedSize > 0 |
|
? new DeflateStream(raw, CompressionMode.Decompress) |
|
: raw; |
|
using var reader = new StreamReader(content, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); |
|
lines = reader.ReadToEnd().Replace("\r\n", "\n").Split('\n'); |
|
break; |
|
} |
|
sourceLines[key] = lines; |
|
return lines; |
|
} |
|
|
|
// The MethodDebugInformation table is parallel to MethodDef: a consumer indexes it by |
|
// method row, so a short table silently shifts every method's debug info. |
|
if (pdb.MethodDebugInformation.Count != pe.MethodDefinitions.Count) |
|
{ |
|
Report(pkg, asm, "<pdb>", new PdbFinding( |
|
"[DOCS] the MethodDebugInformation row count differs from the MethodDef row count")); |
|
} |
|
|
|
bool anySequencePoints = false; |
|
foreach (var infoHandle in pdb.MethodDebugInformation) |
|
{ |
|
var info = pdb.GetMethodDebugInformation(infoHandle); |
|
if (info.SequencePointsBlob.IsNil) |
|
continue; |
|
int rid = MetadataTokens.GetRowNumber(infoHandle); |
|
bodies.TryGetValue(rid, out var facts); |
|
var seen = new HashSet<string>(); |
|
int previousOffset = -1; |
|
foreach (var point in info.GetSequencePoints()) |
|
{ |
|
anySequencePoints = true; |
|
// Offsets are delta-encoded, so a non-increasing one is not just out of order: |
|
// it aliases into the record that means "the document changed here". |
|
if (point.Offset <= previousOffset) |
|
Once(seen, rid, "[SEQPOINT] sequence point offsets are not strictly increasing"); |
|
previousOffset = point.Offset; |
|
if (facts != null && !facts.InstructionOffsets.Contains(point.Offset)) |
|
Once(seen, rid, "[OFFSET] a sequence point offset is not an instruction boundary"); |
|
if (point.IsHidden) |
|
continue; |
|
if (point.StartLine > point.EndLine |
|
|| (point.StartLine == point.EndLine && point.StartColumn >= point.EndColumn)) |
|
{ |
|
Once(seen, rid, "[SEQPOINT] a sequence point span is empty or inverted"); |
|
} |
|
var lines = EmbeddedLines(point.Document); |
|
if (lines == null) |
|
continue; |
|
if (point.EndLine > lines.Length) |
|
Once(seen, rid, "[SOURCE] a sequence point points past the end of the embedded source"); |
|
else if (SpanIsBlank(lines, point.StartLine, point.StartColumn, point.EndLine, point.EndColumn)) |
|
Once(seen, rid, "[SOURCE] a sequence point span covers only whitespace"); |
|
} |
|
} |
|
if (anySequencePoints && pdb.Documents.Count == 0) |
|
Report(pkg, asm, "<pdb>", new PdbFinding("[DOCS] the PDB has sequence points but no documents")); |
|
|
|
// The table is sorted by method, then start offset, then descending length, so the |
|
// enclosing scope of a row is always the innermost one still open. |
|
int currentMethod = 0; |
|
var open = new List<(int Start, int End)>(); |
|
var scopeSeen = new HashSet<string>(); |
|
foreach (var scopeHandle in pdb.LocalScopes) |
|
{ |
|
var scope = pdb.GetLocalScope(scopeHandle); |
|
int rid = MetadataTokens.GetRowNumber(scope.Method); |
|
if (rid != currentMethod) |
|
{ |
|
currentMethod = rid; |
|
open.Clear(); |
|
scopeSeen.Clear(); |
|
} |
|
bodies.TryGetValue(rid, out var facts); |
|
int start = scope.StartOffset, end = scope.EndOffset; |
|
if (facts != null) |
|
{ |
|
if (!facts.InstructionOffsets.Contains(start)) |
|
Once(scopeSeen, rid, "[OFFSET] a local scope starts at an offset that is not an instruction boundary"); |
|
if (end > facts.CodeSize) |
|
Once(scopeSeen, rid, "[OFFSET] a local scope extends past the end of the method body"); |
|
else if (end != facts.CodeSize && !facts.InstructionOffsets.Contains(end)) |
|
Once(scopeSeen, rid, "[OFFSET] a local scope ends at an offset that is not an instruction boundary"); |
|
} |
|
while (open.Count > 0 && open[^1].End <= start) |
|
open.RemoveAt(open.Count - 1); |
|
if (open.Count > 0 && end > open[^1].End) |
|
Once(scopeSeen, rid, "[LOCALS] a local scope is not nested inside its enclosing scope"); |
|
open.Add((start, end)); |
|
var slots = new HashSet<int>(); |
|
foreach (var variableHandle in scope.GetLocalVariables()) |
|
{ |
|
var variable = pdb.GetLocalVariable(variableHandle); |
|
if (facts != null && variable.Index >= facts.LocalCount) |
|
Once(scopeSeen, rid, "[LOCALS] a local variable slot is past the end of the method's local signature"); |
|
if (!slots.Add(variable.Index)) |
|
Once(scopeSeen, rid, "[LOCALS] two local variables in one scope share a slot"); |
|
} |
|
} |
|
|
|
// The expression evaluator resolves unqualified names by walking a scope up to the single |
|
// module-level root. A second root means some scopes hang off nothing, and everything |
|
// under them resolves against an empty chain no matter what the other root holds. |
|
// (An import scope carrying no imports at all is legal - a file need not have usings.) |
|
int rootImportScopes = 0; |
|
foreach (var importScopeHandle in pdb.ImportScopes) |
|
{ |
|
if (pdb.GetImportScope(importScopeHandle).Parent.IsNil) |
|
rootImportScopes++; |
|
} |
|
if (rootImportScopes > 1) |
|
{ |
|
Report(pkg, asm, "<pdb>", new PdbFinding( |
|
"[IMPORTS] the import scope table has more than one root, so some scopes chain to nothing")); |
|
} |
|
|
|
foreach (var infoHandle in pdb.CustomDebugInformation) |
|
{ |
|
var info = pdb.GetCustomDebugInformation(infoHandle); |
|
if (info.Parent.Kind != HandleKind.MethodDefinition) |
|
continue; |
|
int rid = MetadataTokens.GetRowNumber((MethodDefinitionHandle)info.Parent); |
|
bodies.TryGetValue(rid, out var facts); |
|
var kind = pdb.GetGuid(info.Kind); |
|
var seen = new HashSet<string>(); |
|
if (kind == KnownGuids.MethodSteppingInformation) |
|
CheckAsyncStepping(pdb.GetBlobReader(info.Value), rid, facts, seen); |
|
else if (kind == KnownGuids.StateMachineHoistedLocalScopes) |
|
CheckHoistedScopes(pdb.GetBlobReader(info.Value), rid, facts, seen); |
|
} |
|
|
|
void CheckAsyncStepping(BlobReader reader, int rid, BodyFacts? facts, HashSet<string> seen) |
|
{ |
|
try |
|
{ |
|
if (reader.RemainingBytes < 4) |
|
{ |
|
Once(seen, rid, "[ASYNC] the async stepping blob is truncated"); |
|
return; |
|
} |
|
long catchHandler = reader.ReadUInt32(); |
|
if (catchHandler != 0) |
|
{ |
|
// The field is the handler's offset plus one, and only for async void |
|
// methods; 0 otherwise. A consumer decodes it as (value - 1), so a raw |
|
// offset resolves to an address in the middle of an instruction. |
|
var kickoff = pdb.GetMethodDebugInformation(MetadataTokens.MethodDebugInformationHandle(rid)) |
|
.GetStateMachineKickoffMethod(); |
|
// The compiler records a handler for an async void method and for an async entry |
|
// point - both are shapes nothing is expected to await, so the debugger should treat |
|
// the exception as user-unhandled - and an async entry point returns Task. |
|
if (!kickoff.IsNil && !IsUserEntryPoint(pe, kickoff, entryPoint) && !ReturnsVoid(pe, kickoff)) |
|
Once(seen, rid, "[ASYNC] async stepping information records a catch handler for a method whose kickoff method does not return void"); |
|
int decoded = (int)catchHandler - 1; |
|
if (facts != null && !facts.InstructionOffsets.Contains(decoded)) |
|
Once(seen, rid, "[OFFSET] the async stepping catch handler does not decode to an instruction boundary"); |
|
else if (facts != null && !facts.CatchHandlerStarts.Contains(decoded)) |
|
Once(seen, rid, "[ASYNC] the async stepping catch handler does not decode to the start of a catch handler"); |
|
} |
|
while (reader.RemainingBytes > 0) |
|
{ |
|
int yield = (int)reader.ReadUInt32(); |
|
int resume = (int)reader.ReadUInt32(); |
|
int resumeMethod = reader.ReadCompressedInteger(); |
|
if (facts != null && !facts.InstructionOffsets.Contains(yield)) |
|
Once(seen, rid, "[OFFSET] an async yield offset is not an instruction boundary"); |
|
if (facts != null && !facts.InstructionOffsets.Contains(resume)) |
|
Once(seen, rid, "[OFFSET] an async resume offset is not an instruction boundary"); |
|
if (resumeMethod != rid) |
|
Once(seen, rid, "[ASYNC] an async resume method is not the method carrying the stepping information"); |
|
} |
|
} |
|
catch (BadImageFormatException) |
|
{ |
|
Once(seen, rid, "[ASYNC] the async stepping blob is malformed"); |
|
} |
|
} |
|
|
|
void CheckHoistedScopes(BlobReader reader, int rid, BodyFacts? facts, HashSet<string> seen) |
|
{ |
|
int rows = 0; |
|
while (reader.RemainingBytes >= 8) |
|
{ |
|
int start = (int)reader.ReadUInt32(); |
|
int length = (int)reader.ReadUInt32(); |
|
rows++; |
|
if (facts != null && start + length > facts.CodeSize) |
|
Once(seen, rid, "[OFFSET] a state machine hoisted local scope extends past the end of the method body"); |
|
} |
|
// The debugger indexes this table by the slot number it parses out of the state |
|
// machine's own field names, so rows missing at the end drop those locals entirely. |
|
if (rows < HoistedSlotCount(pe, rid)) |
|
Once(seen, rid, "[STATEMACHINE] the state machine hoisted local scope table has fewer rows than the state machine has hoisted slots"); |
|
} |
|
} |
|
|
|
// True when every character the span covers is whitespace - a sequence point the debugger would |
|
// highlight as an empty stretch of the source it is embedded next to. |
|
static bool SpanIsBlank(string[] lines, int startLine, int startColumn, int endLine, int endColumn) |
|
{ |
|
for (int line = startLine; line <= endLine && line <= lines.Length; line++) |
|
{ |
|
var text = lines[line - 1]; |
|
int from = line == startLine ? Math.Min(startColumn - 1, text.Length) : 0; |
|
int to = line == endLine ? Math.Min(endColumn - 1, text.Length) : text.Length; |
|
if (to > from && text[from..to].Trim().Length > 0) |
|
return false; |
|
} |
|
return true; |
|
} |
|
|
|
// A debugger finds a hoisted local by parsing the slot number N out of the state machine's own |
|
// field names and reading row N-1 of the hoisted scope table, so the table has to reach the |
|
// highest slot the debugger will ask for. Only user-visible hoisted fields are ever asked for: |
|
// <name>5__N holds a hoisted local and <>8__N a hoisted display class, while <>s__N compiler |
|
// temporaries and <>u__N awaiters share the same slot counter but are never looked up - which is |
|
// why the compiler's own table stops at the last user-visible slot rather than at the last field. |
|
static int HoistedSlotCount(MetadataReader pe, int methodRid) |
|
{ |
|
var method = pe.GetMethodDefinition(MetadataTokens.MethodDefinitionHandle(methodRid)); |
|
int max = 0; |
|
foreach (var fieldHandle in pe.GetTypeDefinition(method.GetDeclaringType()).GetFields()) |
|
{ |
|
var match = Regex.Match(pe.GetString(pe.GetFieldDefinition(fieldHandle).Name), @"^<.*>[58]__([0-9]+)$"); |
|
if (match.Success) |
|
max = Math.Max(max, int.Parse(match.Groups[1].Value)); |
|
} |
|
return max; |
|
} |
|
|
|
// The assembly's entry point, or nil when there is none or it lives in another module of a |
|
// multi-module assembly (where the token is a File token rather than a method definition). |
|
static MethodDefinitionHandle EntryPoint(PEReader pe) |
|
{ |
|
int token = pe.PEHeaders.CorHeader?.EntryPointTokenOrRelativeVirtualAddress ?? 0; |
|
return (token >> 24) == 0x06 && (token & 0xFFFFFF) != 0 |
|
? MetadataTokens.MethodDefinitionHandle(token & 0xFFFFFF) |
|
: default; |
|
} |
|
|
|
// Whether a state machine's kickoff is the entry point as the user wrote it. An async entry |
|
// point compiles to the user's method plus a '<Main>' wrapper that awaits it, and it is the |
|
// wrapper the entry point token names, so the kickoff is recognised through it: same declaring |
|
// type, and the only two names the compiler gives a user entry point. |
|
static bool IsUserEntryPoint(MetadataReader pe, MethodDefinitionHandle kickoff, MethodDefinitionHandle entryPoint) |
|
{ |
|
if (entryPoint.IsNil) |
|
return false; |
|
if (kickoff == entryPoint) |
|
return true; |
|
var wrapper = pe.GetMethodDefinition(entryPoint); |
|
if (pe.GetString(wrapper.Name) != "<Main>") |
|
return false; |
|
var definition = pe.GetMethodDefinition(kickoff); |
|
var name = pe.GetString(definition.Name); |
|
return (name == "Main" || name == "<Main>$") |
|
&& definition.GetDeclaringType() == wrapper.GetDeclaringType(); |
|
} |
|
|
|
// Reads just the return type out of a method signature: enough to tell an async void kickoff |
|
// method from an async Task one, without a type system or a resolved reference closure. |
|
static bool ReturnsVoid(MetadataReader pe, MethodDefinitionHandle handle) |
|
{ |
|
var reader = pe.GetBlobReader(pe.GetMethodDefinition(handle).Signature); |
|
var header = reader.ReadSignatureHeader(); |
|
if (header.IsGeneric) |
|
reader.ReadCompressedInteger(); |
|
reader.ReadCompressedInteger(); |
|
while (reader.RemainingBytes > 0) |
|
{ |
|
byte element = reader.ReadByte(); |
|
// CMOD_REQD / CMOD_OPT may precede the return type. |
|
if (element is 0x1f or 0x20) |
|
{ |
|
reader.ReadCompressedInteger(); |
|
continue; |
|
} |
|
return element == 0x01; |
|
} |
|
return false; |
|
} |
|
|
|
// The paths of a corpus argument: a dll, or a directory scanned recursively for dlls. |
|
static IEnumerable<string> ExpandDlls(IEnumerable<string> entries) |
|
=> entries |
|
.SelectMany(e => Directory.Exists(e) |
|
? Directory.EnumerateFiles(e, "*.dll", SearchOption.AllDirectories) |
|
: [e]) |
|
.Where(f => !f.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)) |
|
.Distinct() |
|
.OrderBy(f => f, StringComparer.Ordinal); |
|
|
|
void Report(string pkg, string asm, string type, Exception ex) |
|
{ |
|
// Key on the innermost exception so the same defect hit via many members dedupes. |
|
var inner = ex; |
|
while (inner.InnerException != null) |
|
inner = inner.InnerException; |
|
var topFrame = (inner.StackTrace ?? "").Split('\n') |
|
.Select(l => l.Trim()) |
|
.FirstOrDefault(l => l.Contains("ICSharpCode.Decompiler")) ?? ""; |
|
var kind = inner is AssertionFailedException ? "ASSERT" |
|
: inner is TimeoutException ? "TIMEOUT" |
|
: inner is DecompilerWarning ? "WARNING" |
|
: inner is LeakedName ? "LEAK" |
|
: inner is PdbFinding ? "PDB" : "EXCEPTION"; |
|
var key = $"{kind}|{inner.GetType().Name}|{inner.Message}|{topFrame}"; |
|
var location = $"{pkg} / {asm} / {type}"; |
|
if (failures.TryGetValue(key, out var existing)) |
|
{ |
|
failures[key] = existing with { Count = existing.Count + 1 }; |
|
Console.WriteLine($" [{kind}] (dup) {type}: {FirstLine(inner.Message)}"); |
|
} |
|
else |
|
{ |
|
failures[key] = new Finding(kind, inner.GetType().Name, FirstLine(inner.Message), |
|
FirstLine(topFrame), location, ex.ToString(), 1); |
|
pendingContext.Add(key); |
|
Console.WriteLine($" [{kind}] {location}"); |
|
foreach (var line in ex.ToString().Split('\n').Take(30)) |
|
Console.WriteLine(" " + line.TrimEnd()); |
|
} |
|
} |
|
|
|
// One JSON line per finding plus one totals line, appended under a lock so concurrent |
|
// package runs of a sweep can share the file. JSONL because a sweep is append-only and |
|
// may be interrupted at any point: a truncated last line costs one finding, not the file. |
|
static void AppendToLedger(string path, IEnumerable<Finding> findings, int assemblies, int types, |
|
long refsResolved, long refsTotal) |
|
{ |
|
var lines = new List<string>(); |
|
foreach (var f in findings) |
|
{ |
|
// Carry the run's reference-resolution state on every finding: a warning produced |
|
// while references were missing is an artefact of the missing references far more |
|
// often than a decompiler defect ("might be due to ... missing references" is what |
|
// the warning itself says), and the report separates the two on this basis. |
|
lines.Add(JsonSerializer.Serialize(new LedgerEntry("finding", f.Kind, f.ExceptionType, f.Message, |
|
f.Frame, f.FirstLocation, f.Detail, f.Count, 0, 0, refsResolved, refsTotal, f.Context))); |
|
} |
|
lines.Add(JsonSerializer.Serialize(new LedgerEntry("totals", "", "", "", "", "", "", 0, |
|
assemblies, types, refsResolved, refsTotal))); |
|
var full = Path.GetFullPath(path); |
|
Directory.CreateDirectory(Path.GetDirectoryName(full)!); |
|
// Retry briefly: a sweep can have several package runs finishing at once. |
|
for (int attempt = 0; ; attempt++) |
|
{ |
|
try |
|
{ |
|
using var stream = new FileStream(full, FileMode.Append, FileAccess.Write, FileShare.Read); |
|
using var writer = new StreamWriter(stream); |
|
foreach (var line in lines) |
|
writer.WriteLine(line); |
|
return; |
|
} |
|
catch (IOException) when (attempt < 20) |
|
{ |
|
Thread.Sleep(50); |
|
} |
|
} |
|
} |
|
|
|
// Aggregates a sweep's ledger: findings with the same kind/type/message/frame collapse |
|
// into one row carrying the summed hit count, so a defect hit by 400 packages reads as |
|
// one entry, which is the whole point of surveying a sweep. |
|
static void RenderLedger(string ledgerPath, string outPath) |
|
{ |
|
var merged = new Dictionary<string, Finding>(); |
|
var degraded = new HashSet<string>(); // findings only ever seen with references missing |
|
var clean = new HashSet<string>(); |
|
int assemblies = 0, types = 0; |
|
long refsResolved = 0, refsTotal = 0; |
|
int malformed = 0; |
|
foreach (var line in File.ReadLines(ledgerPath)) |
|
{ |
|
LedgerEntry? entry; |
|
try |
|
{ |
|
entry = JsonSerializer.Deserialize<LedgerEntry>(line); |
|
} |
|
catch (JsonException) |
|
{ |
|
malformed++; // truncated tail of an interrupted run |
|
continue; |
|
} |
|
if (entry == null) |
|
continue; |
|
if (entry.Record == "totals") |
|
{ |
|
assemblies += entry.Assemblies; |
|
types += entry.Types; |
|
refsResolved += entry.RefsResolved; |
|
refsTotal += entry.RefsTotal; |
|
continue; |
|
} |
|
var key = $"{entry.Kind}|{entry.ExceptionType}|{entry.Message}|{entry.Frame}"; |
|
merged[key] = merged.TryGetValue(key, out var existing) |
|
? existing with { Count = existing.Count + entry.Count } |
|
: new Finding(entry.Kind, entry.ExceptionType, entry.Message, entry.Frame, |
|
entry.FirstLocation, entry.Detail, entry.Count, entry.Context); |
|
// Ledger lines written before this attribution existed carry 0/0; treat those as |
|
// unknown rather than clean, so they are never presented as confirmed defects. |
|
(entry.RefsTotal > 0 && entry.RefsResolved == entry.RefsTotal ? clean : degraded).Add(key); |
|
} |
|
if (malformed > 0) |
|
Console.WriteLine($" ({malformed} malformed ledger lines skipped)"); |
|
// A finding seen even once with every reference resolved is trustworthy; one that only |
|
// ever appeared in degraded runs is suspect. |
|
degraded.ExceptWith(clean); |
|
WriteHtmlReport(outPath, merged.Values.ToList(), assemblies, types, refsResolved, refsTotal, null, degraded); |
|
} |
|
|
|
// Self-contained HTML view of a fuzz run: findings grouped by kind, most frequent |
|
// first, each expandable to the full exception text. Opens straight off disk - a |
|
// sweep over thousands of packages is far easier to triage here than in scrollback. |
|
static void WriteHtmlReport(string path, List<Finding> findings, int assemblies, int types, |
|
long refsResolved, long refsTotal, string? dumpDir, HashSet<string>? degradedKeys = null) |
|
{ |
|
bool IsDegraded(Finding f) => |
|
degradedKeys?.Contains($"{f.Kind}|{f.ExceptionType}|{f.Message}|{f.Frame}") == true; |
|
string Esc(string s) => s.Replace("&", "&").Replace("<", "<").Replace(">", ">"); |
|
var html = new StringBuilder(); |
|
html.AppendLine(""" |
|
<!doctype html><html><head><meta charset="utf-8"><title>nugetfuzz report</title> |
|
<style> |
|
:root { color-scheme: light dark; --bg:#fff; --fg:#1a1a1a; --muted:#666; --line:#d8d8d8; --chip:#f0f0f0; } |
|
@media (prefers-color-scheme: dark) { :root { --bg:#16181c; --fg:#e6e6e6; --muted:#9aa0a6; --line:#333; --chip:#24262b; } } |
|
body { background:var(--bg); color:var(--fg); font:14px/1.5 system-ui,sans-serif; margin:0 auto; padding:24px; max-width:1100px; } |
|
h1 { font-size:20px; margin:0 0 4px; } h2 { font-size:16px; margin:26px 0 8px; } |
|
.meta { color:var(--muted); font-size:13px; } |
|
details { border:1px solid var(--line); border-radius:6px; margin:6px 0; background:var(--chip); } |
|
summary { cursor:pointer; padding:8px 10px; font-family:ui-monospace,monospace; font-size:13px; } |
|
pre { margin:0; padding:10px; overflow-x:auto; background:var(--bg); font:12px/1.45 ui-monospace,monospace; } |
|
.count { display:inline-block; min-width:3.5em; font-weight:600; } |
|
.suspect { font-size:11px; padding:1px 6px; border-radius:10px; background:#8a6d1f22; |
|
color:#a8791f; border:1px solid #a8791f55; margin-left:6px; } |
|
.ASSERT { border-left:4px solid #d97706; } .EXCEPTION { border-left:4px solid #dc2626; } |
|
.TIMEOUT { border-left:4px solid #7c3aed; } .WARNING { border-left:4px solid #2563eb; } |
|
.PDB { border-left:4px solid #0d9488; } .LEAK { border-left:4px solid #db2777; } |
|
#filter { width:100%; padding:8px; margin:8px 0; border:1px solid var(--line); border-radius:6px; |
|
background:var(--bg); color:var(--fg); font:13px ui-monospace,monospace; } |
|
</style></head><body> |
|
"""); |
|
html.AppendLine("<h1>nugetfuzz report</h1>"); |
|
html.AppendLine($"<div class=meta>{assemblies} assemblies, {types} types decompiled, " |
|
+ $"{refsResolved}/{refsTotal} references resolved, {findings.Count} distinct findings " |
|
+ $"({findings.Sum(f => f.Count)} total)" |
|
+ (dumpDir != null ? $"<br>decompiled sources dumped to {Esc(dumpDir)}" : "") + "</div>"); |
|
html.AppendLine("<input id=filter placeholder='filter by message, type, package or frame'>"); |
|
foreach (var kind in new[] { "ASSERT", "EXCEPTION", "TIMEOUT", "LEAK", "WARNING", "PDB" }) |
|
{ |
|
var group = findings.Where(f => f.Kind == kind).OrderByDescending(f => f.Count).ToList(); |
|
if (group.Count == 0) |
|
continue; |
|
html.AppendLine($"<h2>{kind} ({group.Count} distinct, {group.Sum(f => f.Count)} hits)</h2>"); |
|
foreach (var f in group) |
|
{ |
|
// Only ever seen while references were missing: flagged, not hidden - the |
|
// warning text itself blames missing references, so it is weak evidence. |
|
var suspect = IsDegraded(f) |
|
? " <span class=suspect title='only seen in runs with unresolved references'>refs incomplete</span>" |
|
: ""; |
|
html.AppendLine($"<details class={kind}><summary><span class=count>{f.Count}x</span> " |
|
+ $"{Esc(f.ExceptionType)}: {Esc(f.Message)}{suspect}</summary>"); |
|
var context = f.Context.Length > 0 ? $"{Esc(f.Context)}\n" : ""; |
|
html.AppendLine($"<pre>first: {Esc(f.FirstLocation)}\nframe: {Esc(f.Frame)}\n{context}\n{Esc(f.Detail)}</pre></details>"); |
|
} |
|
} |
|
html.AppendLine(""" |
|
<script> |
|
const box = document.getElementById('filter'); |
|
box.addEventListener('input', () => { |
|
const needle = box.value.toLowerCase(); |
|
for (const d of document.querySelectorAll('details')) |
|
d.style.display = d.textContent.toLowerCase().includes(needle) ? '' : 'none'; |
|
}); |
|
</script> |
|
</body></html> |
|
"""); |
|
File.WriteAllText(path, html.ToString()); |
|
} |
|
|
|
static string SanitizeFileName(string s) |
|
=> string.Concat(s.Split(Path.GetInvalidFileNameChars())); |
|
|
|
static string FirstLine(string s) |
|
{ |
|
var i = s.IndexOfAny(['\r', '\n']); |
|
return i < 0 ? s : s[..i]; |
|
} |
|
|
|
// One deduplicated defect: Count counts every location that hit it, Detail keeps the |
|
// full exception text of the first one for triage. |
|
record Finding(string Kind, string ExceptionType, string Message, string Frame, |
|
string FirstLocation, string Detail, int Count, string Context = "") |
|
{ |
|
public string Describe() |
|
=> $"[{Kind}] {ExceptionType}: {Message} @ {Frame} (first: {FirstLocation})"; |
|
} |
|
|
|
// One line of the sweep ledger: either a deduplicated finding or a per-run totals record. |
|
record LedgerEntry(string Record, string Kind, string ExceptionType, string Message, string Frame, |
|
string FirstLocation, string Detail, int Count, int Assemblies, int Types, |
|
long RefsResolved, long RefsTotal, string Context = ""); |
|
|
|
record VersionIndex(string[] versions); |
|
|
|
// What the PDB lint needs to know about a method body, read from the assembly alone. |
|
record BodyFacts(int CodeSize, HashSet<int> InstructionOffsets, HashSet<int> CatchHandlerStarts, int LocalCount); |
|
|
|
class AssertionFailedException(string message) : Exception(message); |
|
|
|
class DecompilerWarning(string message) : Exception(message); |
|
|
|
// A compiler-generated name that reached the output. The message keeps only the shape of |
|
// the name - the part in angle brackets is the enclosing member and the digits are per |
|
// occurrence - so that one bucket collects every hit of the same unfolded construct. |
|
class LeakedName(string name) |
|
: Exception("leaked compiler-generated name: " |
|
+ Regex.Replace(Regex.Replace(name, "<[^<>]*>", "<>"), "[0-9]+", "N")); |
|
|
|
// A defect in a generated PDB. The message describes the shape of the defect and never the |
|
// instance that hit it - Report() dedupes on the message, so naming a method or an offset in it |
|
// would turn one bug into one finding per method. |
|
class PdbFinding(string message) : Exception(message); |
|
|
|
// Records assertion failures instead of throwing them. Debug.Assert unwinding out of the middle |
|
// of PortablePdbWriter would leave no PDB to check, and the writer is known to assert on |
|
// real-world input; with Trace.Listeners holding only this one, execution continues past the |
|
// assert and still produces a PDB to look at. The call site has to be captured here: nothing |
|
// throws, so there is no stack left to read afterwards, and a message-less Debug.Assert would |
|
// otherwise be reported as an empty string naming no code at all. |
|
class CollectAsserts(List<string> messages) : TraceListener |
|
{ |
|
public override void Fail(string? message, string? detailMessage) |
|
{ |
|
var frame = Environment.StackTrace.Split('\n').Select(l => l.Trim()) |
|
.FirstOrDefault(l => l.Contains("ICSharpCode.Decompiler")) ?? ""; |
|
messages.Add($"{message} {detailMessage}".Trim() + $" @ {frame}"); |
|
} |
|
public override void Write(string? message) |
|
{ |
|
} |
|
public override void WriteLine(string? message) |
|
{ |
|
} |
|
} |
|
|
|
// Resolves assembly references from the given directories (in priority order) before |
|
// falling back to the wrapped resolver, and records every resolution and its outcome. |
|
// The wrapped UniversalAssemblyResolver consults the installed runtime BEFORE its search |
|
// directories, which lets runtime stub facades shadow ref-pack assemblies - hence the |
|
// directory probing happens here, in our order. |
|
class LoggingResolver(IAssemblyResolver inner, List<string> dirs, Func<IAssemblyReference, bool> onMiss) : IAssemblyResolver |
|
{ |
|
public readonly Dictionary<string, string?> Resolutions = new(); |
|
readonly Dictionary<string, MetadataFile?> loaded = new(); |
|
|
|
public IDisposable? BeginSnapshot() => inner.BeginSnapshot(); |
|
|
|
public MetadataFile? Resolve(IAssemblyReference reference) |
|
{ |
|
var file = ResolveFromDirs(reference) ?? inner.Resolve(reference); |
|
if (file == null && onMiss(reference)) |
|
{ |
|
lock (loaded) |
|
{ |
|
loaded.Remove(reference.Name); |
|
} |
|
file = ResolveFromDirs(reference); |
|
} |
|
return Track(reference.FullName, file); |
|
} |
|
public MetadataFile? ResolveModule(MetadataFile mainModule, string moduleName) |
|
=> Track($"{mainModule.Name}!{moduleName}", inner.ResolveModule(mainModule, moduleName)); |
|
public Task<MetadataFile?> ResolveAsync(IAssemblyReference reference) |
|
=> Task.FromResult(Resolve(reference)); |
|
public Task<MetadataFile?> ResolveModuleAsync(MetadataFile mainModule, string moduleName) |
|
=> Task.FromResult(ResolveModule(mainModule, moduleName)); |
|
|
|
MetadataFile? ResolveFromDirs(IAssemblyReference reference) |
|
{ |
|
string[] snapshot; |
|
lock (dirs) |
|
{ |
|
snapshot = dirs.ToArray(); |
|
} |
|
lock (loaded) |
|
{ |
|
if (loaded.TryGetValue(reference.Name, out var cached)) |
|
return cached; |
|
MetadataFile? result = null; |
|
foreach (var dir in snapshot) |
|
{ |
|
var path = Path.Combine(dir, reference.Name + ".dll"); |
|
if (!File.Exists(path)) |
|
continue; |
|
try |
|
{ |
|
result = new PEFile(path); |
|
break; |
|
} |
|
catch (Exception) |
|
{ |
|
// unreadable candidate; keep probing lower-priority dirs |
|
} |
|
} |
|
loaded[reference.Name] = result; |
|
return result; |
|
} |
|
} |
|
|
|
MetadataFile? Track(string key, MetadataFile? file) |
|
{ |
|
lock (Resolutions) |
|
{ |
|
Resolutions[key] = file?.FileName; |
|
} |
|
return file; |
|
} |
|
} |
|
|
|
class ThrowOnAssert : TraceListener |
|
{ |
|
public override void Fail(string? message, string? detailMessage) |
|
=> throw new AssertionFailedException($"{message} {detailMessage}".Trim()); |
|
public override void Write(string? message) |
|
{ |
|
} |
|
public override void WriteLine(string? message) |
|
{ |
|
} |
|
}
|
|
|