diff --git a/BUILDGUIDE.md b/BUILDGUIDE.md index 5acf76cbd5..12a91935cc 100644 --- a/BUILDGUIDE.md +++ b/BUILDGUIDE.md @@ -92,7 +92,7 @@ projects they depend on. | `BuildSqlClientWindows` | Builds the Windows-specific implementation binaries of Microsoft.Data.SqlClient | | `BuildSqlServer` | Builds Microsoft.SqlServer.Server | | `BuildTests` | Builds all test projects for all supported OS combinations | -| `BuildTools` | Builds auxiliary tool/app projects | +| `BuildTools` | Builds auxiliary tool/app projects and their test projects | | `Clean` | Removes build and test output directories | A selection of parameters for build targets in `build.proj` can be found below: diff --git a/build.proj b/build.proj index f2ee21f77c..7a8ce075a7 100644 --- a/build.proj +++ b/build.proj @@ -1474,10 +1474,15 @@ $(RepoRoot)doc/apps/AzureSqlConnector/AzureSqlConnector.csproj $(RepoRoot)tools/PackageCompatibility/src/PackageCompatibility.csproj + $(RepoRoot)tools/PackageCompatibility/test/PackageCompatibility.Test.csproj + $(RepoRoot)tools/PackageCompatibility + $(RepoRoot)tools/PackageValidator/src/PackageValidator.csproj + $(RepoRoot)tools/PackageValidator/test/PackageValidator.Test.csproj + $(RepoRoot)tools/PackageValidator - - + + @@ -1507,4 +1512,84 @@ + + + + + "$(DotnetPath)dotnet" build "$(PackageValidatorProjectPath)" + -p:Configuration=$(Configuration) + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + + + + + + + "$(DotnetPath)dotnet" build "$(PackageCompatibilityTestProjectPath)" + -p:Configuration=$(Configuration) + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + + + + + + + "$(DotnetPath)dotnet" build "$(PackageValidatorTestProjectPath)" + -p:Configuration=$(Configuration) + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + + + + + + + "$(DotnetPath)dotnet" test "$(PackageCompatibilityTestProjectPath)" + -p:Configuration=$(Configuration) + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + + + + + + + "$(DotnetPath)dotnet" test "$(PackageValidatorTestProjectPath)" + -p:Configuration=$(Configuration) + + $([System.Text.RegularExpressions.Regex]::Replace($(DotnetCommand), "\s+", " ")) + + + + + + diff --git a/src/Microsoft.Data.SqlClient.slnx b/src/Microsoft.Data.SqlClient.slnx index c0c12132e7..62e0612395 100644 --- a/src/Microsoft.Data.SqlClient.slnx +++ b/src/Microsoft.Data.SqlClient.slnx @@ -214,6 +214,10 @@ - - + + + + + + diff --git a/tools/PackageValidator/Directory.Build.props b/tools/PackageValidator/Directory.Build.props new file mode 100644 index 0000000000..425707ecad --- /dev/null +++ b/tools/PackageValidator/Directory.Build.props @@ -0,0 +1,10 @@ + + + + + + + true + + + diff --git a/tools/PackageValidator/Directory.Packages.props b/tools/PackageValidator/Directory.Packages.props new file mode 100644 index 0000000000..f3a6395bc4 --- /dev/null +++ b/tools/PackageValidator/Directory.Packages.props @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/tools/PackageValidator/global.json b/tools/PackageValidator/global.json new file mode 100644 index 0000000000..a4b67c1ed4 --- /dev/null +++ b/tools/PackageValidator/global.json @@ -0,0 +1,15 @@ +{ + "test": { + // xUnit v3 runs on Microsoft.Testing.Platform rather than the older VSTest runner. + // On .NET 10, dotnet test must therefore be opted into MTP mode for this test project. + "runner": "Microsoft.Testing.Platform" + }, + "sdk": { + // global.json lookup stops at the nearest file and does not merge with the repo-root file. + // This subtree therefore needs to repeat the root SDK settings so commands run from + // tools/PackageValidator keep using the same SDK behavior. + "version": "10.0.300", + "rollForward": "patch", + "allowPrerelease": false + } +} diff --git a/tools/PackageValidator/src/AssemblyInspector.cs b/tools/PackageValidator/src/AssemblyInspector.cs new file mode 100644 index 0000000000..82d134540e --- /dev/null +++ b/tools/PackageValidator/src/AssemblyInspector.cs @@ -0,0 +1,305 @@ +using System.IO.Compression; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Text; + +namespace PackageValidator; + +/// +/// Inspects a single .dll archive entry and extracts its identity, version, signing, and +/// debug information without loading the assembly into the runtime. +/// +internal static class AssemblyInspector +{ + /// + /// Inspects a single .dll archive entry. + /// + /// The .dll entry within the package archive. + /// + /// A . For native or non-assembly DLLs this is a lightweight report + /// with set to . + /// + public static BinaryReport Inspect(ZipArchiveEntry entry) + { + // PEReader requires a seekable stream, but the zip entry stream is forward-only, so copy the + // bytes into a seekable buffer first. + byte[] bytes; + using (var buffer = new MemoryStream()) + { + using (Stream stream = entry.Open()) + { + stream.CopyTo(buffer); + } + bytes = buffer.ToArray(); + } + + try + { + using var pe = new PEReader(new MemoryStream(bytes, writable: false)); + + // Native DLLs (for example the SNI runtimes) have no CLI metadata at all; report their + // Win32 version-resource information instead of managed identity. + if (!pe.HasMetadata) + { + return BinaryReport.Native(entry.FullName, NativeVersionReader.Read(bytes)); + } + + MetadataReader reader = pe.GetMetadataReader(); + + // A managed module that is not itself an assembly (no manifest) carries no + // version/strong-name identity. + if (!reader.IsAssembly) + { + return new BinaryReport + { + Path = entry.FullName, + Kind = BinaryKind.Other, + IsManagedAssembly = false, + }; + } + + AssemblyDefinition asm = reader.GetAssemblyDefinition(); + + // The assembly version and culture come straight from the assembly manifest row. + string name = reader.GetString(asm.Name); + string assemblyVersion = asm.Version.ToString(); + string culture = asm.Culture.IsNil ? "neutral" : reader.GetString(asm.Culture); + if (string.IsNullOrEmpty(culture)) + { + culture = "neutral"; + } + + // The public key (if the assembly is strong-name signed) yields the public key token. + byte[] publicKey = reader.GetBlobBytes(asm.PublicKey); + string? publicKeyToken = ComputePublicKeyToken(publicKey); + + // The strong-name signed CorFlag distinguishes a fully signed assembly from a + // delay-signed one (which carries a public key but was never signed). + SigningStatus signing = DetermineSigningStatus(pe, publicKeyToken is not null); + + // Read the debug directory to learn which PDB (by GUID) this assembly was built with, + // whether it already carries an embedded portable PDB, and any recorded PDB checksums. + (Guid? codeViewGuid, bool hasEmbeddedPdb, List? checksums) = ReadDebugInfo(pe); + + // File, informational, and target-framework versions are assembly-level custom + // attributes, so scan for them by attribute type name. + string? fileVersion = null; + string? informationalVersion = null; + string? targetFramework = null; + foreach (CustomAttributeHandle handle in asm.GetCustomAttributes()) + { + CustomAttribute attribute = reader.GetCustomAttribute(handle); + switch (GetAttributeTypeName(reader, attribute)) + { + case "System.Reflection.AssemblyFileVersionAttribute": + fileVersion = DecodeStringArgument(reader, attribute); + break; + case "System.Reflection.AssemblyInformationalVersionAttribute": + informationalVersion = DecodeStringArgument(reader, attribute); + break; + case "System.Runtime.Versioning.TargetFrameworkAttribute": + targetFramework = DecodeStringArgument(reader, attribute); + break; + } + } + + // Compose the canonical strong-name display name in the same shape AssemblyName.FullName + // would produce, using "null" when the assembly is not strong-name signed. + string strongName = + $"{name}, Version={assemblyVersion}, Culture={culture}, " + + $"PublicKeyToken={publicKeyToken ?? "null"}"; + + return new BinaryReport + { + Path = entry.FullName, + Kind = BinaryClassifier.Classify(entry.FullName), + IsManagedAssembly = true, + AssemblyName = name, + AssemblyVersion = assemblyVersion, + FileVersion = fileVersion, + InformationalVersion = informationalVersion, + TargetFramework = targetFramework, + Culture = culture, + PublicKeyToken = publicKeyToken, + SigningStatus = signing, + StrongName = strongName, + CodeViewGuid = codeViewGuid, + Checksums = checksums, + HasEmbeddedSymbols = hasEmbeddedPdb, + }; + } + catch (BadImageFormatException) + { + // The entry was not a valid PE image (for example a renamed data file). Report it as + // native rather than failing the whole package inspection. + return BinaryReport.Native(entry.FullName); + } + } + + /// + /// Determines the strong-name signing state of an assembly from its CLI header flags. + /// + /// The PE reader positioned over the assembly. + /// Whether the assembly manifest declares a public key. + /// The inferred . + private static SigningStatus DetermineSigningStatus(PEReader pe, bool hasPublicKey) + { + if (!hasPublicKey) + { + return PackageValidator.SigningStatus.Unsigned; + } + + CorHeader? cor = pe.PEHeaders.CorHeader; + bool strongNameSigned = cor is not null && (cor.Flags & CorFlags.StrongNameSigned) != 0; + return strongNameSigned + ? PackageValidator.SigningStatus.Signed + : PackageValidator.SigningStatus.DelaySigned; + } + + /// + /// Reads an assembly's debug directory to determine the PDB it was built against, whether it + /// embeds a portable PDB, and any recorded PDB checksums. + /// + /// The PE reader positioned over the assembly. + /// The CodeView GUID, the embedded-PDB flag, and the recorded checksums (if any). + private static (Guid? CodeViewGuid, bool HasEmbeddedPdb, List? Checksums) ReadDebugInfo(PEReader pe) + { + Guid? codeViewGuid = null; + bool hasEmbeddedPdb = false; + List? checksums = null; + + foreach (DebugDirectoryEntry entry in pe.ReadDebugDirectory()) + { + switch (entry.Type) + { + case DebugDirectoryEntryType.CodeView: + try + { + codeViewGuid = pe.ReadCodeViewDebugDirectoryData(entry).Guid; + } + catch (BadImageFormatException) + { + // Malformed CodeView record; leave the GUID unknown. + } + break; + + case DebugDirectoryEntryType.EmbeddedPortablePdb: + hasEmbeddedPdb = true; + break; + + case DebugDirectoryEntryType.PdbChecksum: + try + { + PdbChecksumDebugDirectoryData data = pe.ReadPdbChecksumDebugDirectoryData(entry); + (checksums ??= []).Add(new PdbChecksum + { + Algorithm = data.AlgorithmName, + Hash = data.Checksum.ToArray(), + }); + } + catch (BadImageFormatException) + { + // Malformed checksum record; skip it. + } + break; + } + } + + return (codeViewGuid, hasEmbeddedPdb, checksums); + } + + /// + /// Computes the 8-byte public key token (as a lowercase hex string) from a strong-name public + /// key blob. + /// + /// The raw public key bytes from the assembly manifest. + /// + /// The 16-character hex public key token, or when the assembly is not + /// strong-name signed (empty public key). + /// + public static string? ComputePublicKeyToken(byte[] publicKey) + { + if (publicKey is null || publicKey.Length == 0) + { + return null; + } + + // The token is defined by the CLI spec as the low 8 bytes of the SHA-1 hash of the public + // key, emitted in reverse (little-endian) order. + byte[] hash = SHA1.HashData(publicKey); + var token = new byte[8]; + for (int i = 0; i < 8; i++) + { + token[i] = hash[hash.Length - 1 - i]; + } + + var sb = new StringBuilder(16); + foreach (byte b in token) + { + sb.Append(b.ToString("x2")); + } + return sb.ToString(); + } + + /// + /// Resolves the full type name of the attribute that a custom attribute instance constructs. + /// + private static string? GetAttributeTypeName(MetadataReader reader, CustomAttribute attribute) + { + switch (attribute.Constructor.Kind) + { + case HandleKind.MemberReference: + MemberReference memberRef = reader.GetMemberReference((MemberReferenceHandle)attribute.Constructor); + return GetTypeName(reader, memberRef.Parent); + + case HandleKind.MethodDefinition: + MethodDefinition methodDef = reader.GetMethodDefinition((MethodDefinitionHandle)attribute.Constructor); + return GetTypeName(reader, methodDef.GetDeclaringType()); + + default: + return null; + } + } + + /// + /// Builds the fully qualified name of a type referenced by a type reference or definition handle. + /// + private static string? GetTypeName(MetadataReader reader, EntityHandle handle) + { + switch (handle.Kind) + { + case HandleKind.TypeReference: + TypeReference typeRef = reader.GetTypeReference((TypeReferenceHandle)handle); + return Combine(reader.GetString(typeRef.Namespace), reader.GetString(typeRef.Name)); + + case HandleKind.TypeDefinition: + TypeDefinition typeDef = reader.GetTypeDefinition((TypeDefinitionHandle)handle); + return Combine(reader.GetString(typeDef.Namespace), reader.GetString(typeDef.Name)); + + default: + return null; + } + + static string Combine(string ns, string name) => + string.IsNullOrEmpty(ns) ? name : $"{ns}.{name}"; + } + + /// + /// Decodes the value of a custom attribute whose constructor takes a single string argument. + /// + private static string? DecodeStringArgument(MetadataReader reader, CustomAttribute attribute) + { + BlobReader blob = reader.GetBlobReader(attribute.Value); + + // Every custom attribute value blob begins with a fixed 0x0001 prolog; anything else means + // the blob is malformed or not the single-string shape expected here. + if (blob.RemainingBytes < 2 || blob.ReadUInt16() != 0x0001) + { + return null; + } + + return blob.ReadSerializedString(); + } +} diff --git a/tools/PackageValidator/src/BinaryClassifier.cs b/tools/PackageValidator/src/BinaryClassifier.cs new file mode 100644 index 0000000000..f4ec08b956 --- /dev/null +++ b/tools/PackageValidator/src/BinaryClassifier.cs @@ -0,0 +1,41 @@ +namespace PackageValidator; + +/// +/// Classifies a DLL path into the role it plays inside a NuGet package. +/// +internal static class BinaryClassifier +{ + /// + /// Classifies a managed DLL by its path within the package. + /// + /// The DLL path within the package archive (forward-slash separated). + /// The inferred (never ). + public static BinaryKind Classify(string path) + { + string normalized = path.Replace('\\', '/'); + string fileName = System.IO.Path.GetFileName(normalized); + + // Satellite resource assemblies are named ".resources.dll" and live under a culture + // subfolder. The name suffix alone is a reliable discriminator. + if (fileName.EndsWith(".resources.dll", StringComparison.OrdinalIgnoreCase)) + { + return BinaryKind.Satellite; + } + + // Reference assemblies are shipped under ref/ and carry no executable IL. + if (normalized.StartsWith("ref/", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("/ref/", StringComparison.OrdinalIgnoreCase)) + { + return BinaryKind.Reference; + } + + // Implementation assemblies live under lib/ or runtimes//lib/. + if (normalized.StartsWith("lib/", StringComparison.OrdinalIgnoreCase) + || normalized.Contains("/lib/", StringComparison.OrdinalIgnoreCase)) + { + return BinaryKind.Implementation; + } + + return BinaryKind.Other; + } +} diff --git a/tools/PackageValidator/src/HumanReporter.cs b/tools/PackageValidator/src/HumanReporter.cs new file mode 100644 index 0000000000..162542626a --- /dev/null +++ b/tools/PackageValidator/src/HumanReporter.cs @@ -0,0 +1,235 @@ +namespace PackageValidator; + +/// +/// Renders a to standard output in a human-readable layout. +/// +internal static class HumanReporter +{ + /// + /// Writes the full run: each package's details and findings, then any cross-package findings and + /// the run summary. + /// + /// The validation run to render. + public static void Print(ValidationRun run) + { + for (int i = 0; i < run.Packages.Count; i++) + { + if (i > 0) + { + Console.WriteLine(new string('=', 78)); + Console.WriteLine(); + } + + PrintPackage(run.Packages[i]); + } + + if (run.CrossPackageFindings is { Count: > 0 } cross) + { + Console.WriteLine(new string('=', 78)); + Console.WriteLine(); + Console.WriteLine("Cross-package findings:"); + foreach (Finding finding in cross) + { + Console.WriteLine($" {FormatFinding(finding)}"); + } + Console.WriteLine(); + } + + PrintSummary(run.Summary); + } + + /// + /// Writes a single package's details and findings. + /// + /// The package report to render. + private static void PrintPackage(PackageReport report) + { + SymbolPackageInfo sym = report.SymbolPackage; + + Console.WriteLine($"Package file: {report.PackageFile}"); + Console.WriteLine($"NuGet package id: {report.PackageId ?? "(unknown)"}"); + Console.WriteLine($"NuGet version: {report.PackageVersion ?? "(unknown)"}"); + Console.WriteLine($"Package signed: {(report.IsSigned ? "yes" : "no")}"); + Console.WriteLine($"Symbol package: {DescribeSymbolPackage(sym)}"); + + PrintDependencies(report.DependencyGroups); + Console.WriteLine(); + + if (report.Binaries.Count == 0) + { + Console.WriteLine("No .dll files found in package."); + } + else + { + foreach (BinaryReport asm in report.Binaries) + { + PrintBinary(asm, sym); + } + + PrintSymbolSummary(sym); + } + + PrintFindings(report.Findings); + Console.WriteLine(); + } + + /// Writes a package's declared dependencies. + private static void PrintDependencies(List? groups) + { + if (groups is not { Count: > 0 }) + { + return; + } + + Console.WriteLine("Dependencies:"); + foreach (DependencyGroup group in groups) + { + string tfm = group.TargetFramework ?? "(all frameworks)"; + Console.WriteLine($" [{tfm}]"); + if (group.Dependencies.Count == 0) + { + Console.WriteLine(" (none)"); + continue; + } + foreach (DependencyInfo dependency in group.Dependencies) + { + Console.WriteLine($" {dependency.Id} {dependency.VersionRange ?? "(any)"}"); + } + } + } + + /// Writes a single binary's details. + private static void PrintBinary(BinaryReport asm, SymbolPackageInfo sym) + { + Console.WriteLine($"{asm.Path} [{asm.Kind}]"); + + if (!asm.IsManagedAssembly) + { + Console.WriteLine(" (native / unmanaged DLL - no managed metadata)"); + if (asm.NativeVersion is { } native) + { + Console.WriteLine($" File version: {native.FileVersion ?? "(none)"}"); + Console.WriteLine($" Product version: {native.ProductVersion ?? "(none)"}"); + Console.WriteLine($" Product name: {native.ProductName ?? "(none)"}"); + Console.WriteLine($" Architecture: {native.Architecture ?? "(unknown)"}"); + } + Console.WriteLine(); + return; + } + + Console.WriteLine($" Assembly version: {asm.AssemblyVersion}"); + Console.WriteLine($" File version: {asm.FileVersion ?? "(none)"}"); + Console.WriteLine($" Informational version: {asm.InformationalVersion ?? "(none)"}"); + Console.WriteLine($" Target framework: {asm.TargetFramework ?? "(none)"}"); + Console.WriteLine($" Public key token: {asm.PublicKeyToken ?? "(none)"}"); + Console.WriteLine($" Signing status: {asm.SigningStatus?.ToString() ?? "(unknown)"}"); + Console.WriteLine($" Strong name: {asm.StrongName}"); + Console.WriteLine($" Debug (PDB) id: {asm.DebugId ?? "(none)"}"); + Console.WriteLine($" Embedded symbols: {FormatTriState(asm.HasEmbeddedSymbols)}"); + + if (sym.Status == "present") + { + Console.WriteLine($" Symbol package PDB: {DescribeSymbolPackagePdb(asm)}"); + } + + Console.WriteLine(); + } + + /// Renders the package header's symbol-package line. + private static string DescribeSymbolPackage(SymbolPackageInfo sym) => sym.Status switch + { + "present" => sym.File!, + "missing" => "(no sibling .snupkg found beside the package)", + "skipped" => "(symbol package processing disabled via --no-snupkg)", + _ => sym.Status, + }; + + /// Describes whether the symbol package supplies a matching PDB for an assembly. + private static string DescribeSymbolPackagePdb(BinaryReport asm) + { + if (asm.HasSymbolPackageSymbols != true) + { + return "none"; + } + + if (asm.SymbolPackageSymbolsMatch != true) + { + return $"{asm.SymbolPackageFile} (MISMATCH - symbols are from a different build)"; + } + + string checksum = asm.SymbolPackageVerifiedByChecksum switch + { + true => "checksum verified", + false => "CHECKSUM MISMATCH", + null => "GUID match, checksum not verified", + }; + return $"{asm.SymbolPackageFile} (matches assembly; {checksum})"; + } + + /// Writes the trailing symbol-coverage summary block. + private static void PrintSymbolSummary(SymbolPackageInfo sym) + { + Console.WriteLine("Symbol summary:"); + Console.WriteLine($" Implementation assemblies have symbols: {FormatTriState(sym.AllImplementationAssembliesHaveSymbols)}"); + + switch (sym.Status) + { + case "present": + Console.WriteLine($" All symbol-package symbols match: {FormatTriState(sym.AllSymbolsMatch)}"); + if (sym.OrphanSymbolFiles is { Count: > 0 } orphans) + { + Console.WriteLine(" Symbol files with no matching assembly:"); + foreach (string orphan in orphans) + { + Console.WriteLine($" {orphan}"); + } + } + break; + + case "missing": + Console.WriteLine(" Symbol package: none found beside the package (embedded symbols still evaluated)."); + break; + + case "skipped": + Console.WriteLine(" Symbol package: processing disabled via --no-snupkg (embedded symbols still evaluated)."); + break; + } + + Console.WriteLine(); + } + + /// Writes a package's findings, grouped under a heading. + private static void PrintFindings(List? findings) + { + if (findings is not { Count: > 0 }) + { + Console.WriteLine("Findings: none"); + return; + } + + Console.WriteLine("Findings:"); + foreach (Finding finding in findings) + { + Console.WriteLine($" {FormatFinding(finding)}"); + } + } + + /// Formats a single finding as a one-line entry. + private static string FormatFinding(Finding finding) => + $"[{finding.Severity.ToString().ToUpperInvariant()}] {finding.Category}: {finding.Target} - {finding.Message}"; + + /// Writes the run-level summary. + private static void PrintSummary(ValidationSummary summary) + { + Console.WriteLine("Run summary:"); + Console.WriteLine($" Packages inspected: {summary.PackageCount}"); + Console.WriteLine($" Errors: {summary.ErrorCount}"); + Console.WriteLine($" Warnings: {summary.WarningCount}"); + Console.WriteLine($" Info: {summary.InfoCount}"); + Console.WriteLine($" Gate: {(summary.Failed ? "FAILED" : "passed")}"); + } + + /// Formats a nullable boolean as yes/no/n/a. + private static string FormatTriState(bool? value) => + value switch { true => "yes", false => "no", null => "n/a" }; +} diff --git a/tools/PackageValidator/src/Json.cs b/tools/PackageValidator/src/Json.cs new file mode 100644 index 0000000000..43aa35a2b5 --- /dev/null +++ b/tools/PackageValidator/src/Json.cs @@ -0,0 +1,36 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace PackageValidator; + +/// +/// Source-generated for the validation run output, providing +/// trim- and AOT-friendly serialization with indented, camel-cased output that omits null members +/// and writes enums as strings. +/// +[JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + UseStringEnumConverter = true)] +[JsonSerializable(typeof(ValidationRun))] +internal sealed partial class JsonContext : JsonSerializerContext +{ + private static JsonSerializerOptions? s_serializerOptions; + + /// + /// Gets serializer options based on this context's generated metadata but with relaxed escaping, + /// so characters that are safe inside a JSON string (such as +) are emitted literally + /// rather than as \uXXXX escapes. + /// + /// + /// Built lazily rather than in a field initializer to avoid a static-initialization ordering + /// dependency on the source-generated instance. + /// + public static JsonSerializerOptions SerializerOptions => + s_serializerOptions ??= new(Default.Options) + { + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; +} diff --git a/tools/PackageValidator/src/Models.Common.cs b/tools/PackageValidator/src/Models.Common.cs new file mode 100644 index 0000000000..e5bafd50d9 --- /dev/null +++ b/tools/PackageValidator/src/Models.Common.cs @@ -0,0 +1,135 @@ +using System.Text.Json.Serialization; + +namespace PackageValidator; + +/// +/// Classifies a DLL by the role it plays inside a NuGet package. Symbol-coverage expectations +/// differ by kind: only assemblies are expected to ship symbols, +/// while reference assemblies and satellite resource assemblies legitimately have none. +/// +internal enum BinaryKind +{ + /// An implementation assembly under lib/ or runtimes/.../lib/. + Implementation, + + /// A reference (compile-time) assembly under ref/. + Reference, + + /// A satellite resource assembly (*.resources.dll in a culture subfolder). + Satellite, + + /// A native (unmanaged) binary, such as an SNI runtime. + Native, + + /// A managed DLL that does not fit any of the other categories. + Other, +} + +/// +/// The strong-name signing state of a managed assembly. +/// +internal enum SigningStatus +{ + /// The assembly has no public key and is not strong-name signed. + Unsigned, + + /// + /// The assembly carries a public key but the strong-name signed flag is not set, meaning it was + /// delay-signed and never completed signing. + /// + DelaySigned, + + /// The assembly carries a public key and the strong-name signed flag is set. + Signed, +} + +/// +/// The severity of a validation finding. +/// +internal enum Severity +{ + /// Informational; surfaced for visibility but not necessarily a problem. + Info, + + /// A potential problem that may warrant attention. + Warning, + + /// A definite problem. + Error, +} + +/// +/// A single validation result produced by the rules engine. +/// +internal sealed class Finding +{ + /// Gets the severity of the finding. + public required Severity Severity { get; init; } + + /// Gets the stable category key (for example "missing-symbols") used by --fail-on. + public required string Category { get; init; } + + /// Gets the target the finding applies to (a package file, an assembly path, etc.). + public required string Target { get; init; } + + /// Gets the human-readable explanation of the finding. + public required string Message { get; init; } +} + +/// +/// A NuGet package dependency declared in the .nuspec. +/// +internal sealed class DependencyInfo +{ + /// Gets the dependency package id. + public required string Id { get; init; } + + /// Gets the declared version range, or if none was specified. + public string? VersionRange { get; init; } +} + +/// +/// A group of dependencies, optionally scoped to a single target framework. +/// +internal sealed class DependencyGroup +{ + /// Gets the target framework moniker for this group, or for the framework-agnostic group. + public string? TargetFramework { get; init; } + + /// Gets the dependencies declared in this group. + public required List Dependencies { get; init; } +} + +/// +/// Win32 version-resource information read from a native binary. +/// +internal sealed class NativeVersionInfo +{ + /// Gets the FileVersion string from the Win32 version resource, if present. + public string? FileVersion { get; init; } + + /// Gets the ProductVersion string from the Win32 version resource, if present. + public string? ProductVersion { get; init; } + + /// Gets the ProductName string from the Win32 version resource, if present. + public string? ProductName { get; init; } + + /// Gets the processor architecture of the native image (for example "x64"), if known. + public string? Architecture { get; init; } +} + +/// +/// A PDB checksum recorded in an assembly's debug directory, used to verify a matched PDB byte-for-byte. +/// +internal sealed class PdbChecksum +{ + /// Gets the hash algorithm name (for example "SHA256"). + public required string Algorithm { get; init; } + + /// Gets the checksum bytes, excluded from JSON output to keep it compact. + [JsonIgnore] + public required byte[] Hash { get; init; } + + /// Gets the checksum as a lowercase hex string for display. + public string HashHex => Convert.ToHexStringLower(Hash); +} diff --git a/tools/PackageValidator/src/Models.Report.cs b/tools/PackageValidator/src/Models.Report.cs new file mode 100644 index 0000000000..6ff2daaee3 --- /dev/null +++ b/tools/PackageValidator/src/Models.Report.cs @@ -0,0 +1,234 @@ +using System.Text.Json.Serialization; + +namespace PackageValidator; + +/// +/// The top-level result of a validation run over one or more NuGet packages. +/// +internal sealed class ValidationRun +{ + /// Gets the per-package reports, ordered by package file name. + public required List Packages { get; init; } + + /// + /// Gets findings that span multiple packages (for example dependency version inconsistencies + /// between packages in the same batch), or when there are none. + /// + public List? CrossPackageFindings { get; set; } + + /// Gets the run-level summary counts. + public required ValidationSummary Summary { get; set; } +} + +/// +/// Aggregate counts describing the outcome of a validation run. +/// +internal sealed class ValidationSummary +{ + /// Gets the number of packages inspected. + public required int PackageCount { get; init; } + + /// Gets the total number of findings across the run. + public required int ErrorCount { get; set; } + + /// Gets the total number of findings across the run. + public required int WarningCount { get; set; } + + /// Gets the total number of findings across the run. + public required int InfoCount { get; set; } + + /// + /// Gets a value indicating whether the run failed its configured --fail-on gate. + /// when no gate was configured or nothing matched it. + /// + public bool Failed { get; set; } +} + +/// +/// The result of inspecting and validating a single NuGet package: its identity, dependencies, +/// signature state, a report for each DLL it contains, the state of its sibling symbol package, +/// and the findings produced by the rules engine. +/// +internal sealed class PackageReport +{ + /// Gets the file name of the inspected .nupkg. + public required string PackageFile { get; init; } + + /// Gets the package id from the .nuspec, or if unavailable. + public string? PackageId { get; init; } + + /// Gets the package version from the .nuspec, or if unavailable. + public string? PackageVersion { get; init; } + + /// Gets a value indicating whether the package carries a NuGet author/repository signature. + public bool IsSigned { get; init; } + + /// Gets the dependency groups declared in the .nuspec, or if none. + public List? DependencyGroups { get; init; } + + /// Gets the per-DLL reports, ordered by their path within the package. + public required List Binaries { get; init; } + + /// Gets the state and findings of the sibling .snupkg symbol package. + public required SymbolPackageInfo SymbolPackage { get; init; } + + /// Gets the validation findings for this package, or when there are none. + public List? Findings { get; set; } +} + +/// +/// Package-level summary of the sibling .snupkg symbol package and how well its contents +/// correspond to the assemblies in the main package. +/// +internal sealed class SymbolPackageInfo +{ + /// + /// Gets the processing state: "present" (a sibling symbol package was found and checked), + /// "missing" (no sibling symbol package exists), or "skipped" (processing was + /// disabled via --no-snupkg). + /// + public required string Status { get; init; } + + /// Gets the symbol package file name, or unless is "present". + public string? File { get; init; } + + /// + /// Gets whether every implementation assembly has usable symbols (embedded, or a matching + /// symbol-package PDB). when there are no implementation assemblies. + /// + public bool? AllImplementationAssembliesHaveSymbols { get; init; } + + /// + /// Gets whether every assembly with symbol-package symbols has symbols that match its build. + /// when no symbol package was processed or none of its symbols apply. + /// + public bool? AllSymbolsMatch { get; init; } + + /// + /// Gets the symbol package PDB paths that did not correspond to any assembly, or + /// when there are none. + /// + public List? OrphanSymbolFiles { get; init; } +} + +/// +/// Version, identity, signing, and symbol information for a single DLL found inside a NuGet +/// package. For native or non-assembly DLLs, the managed fields are and +/// carries any Win32 version-resource data instead. +/// +internal sealed class BinaryReport +{ + /// Gets the path of the DLL within the package archive. + public required string Path { get; init; } + + /// Gets the role this DLL plays inside the package. + public required BinaryKind Kind { get; init; } + + /// + /// Gets a value indicating whether the DLL is a managed assembly with a manifest. When + /// , the managed version fields are . + /// + public bool IsManagedAssembly { get; init; } + + /// Gets the simple assembly name from the manifest. + public string? AssemblyName { get; init; } + + /// Gets the four-part assembly version (the .NET assembly version). + public string? AssemblyVersion { get; init; } + + /// Gets the value of , if present. + public string? FileVersion { get; init; } + + /// Gets the value of , if present. + public string? InformationalVersion { get; init; } + + /// Gets the value of , if present. + public string? TargetFramework { get; init; } + + /// Gets the assembly culture, or "neutral" for culture-independent assemblies. + public string? Culture { get; init; } + + /// Gets the strong-name public key token, or if unsigned. + public string? PublicKeyToken { get; init; } + + /// Gets the strong-name signing state of the assembly. + public SigningStatus? SigningStatus { get; init; } + + /// Gets the full strong-name display name (name, version, culture, public key token). + public string? StrongName { get; init; } + + /// Gets the Win32 version-resource information for a native binary, if any. + public NativeVersionInfo? NativeVersion { get; init; } + + /// + /// Gets the assembly's CodeView debug GUID used to match it to a PDB. Excluded from serialized + /// output; the formatted is emitted instead. + /// + [JsonIgnore] + public Guid? CodeViewGuid { get; init; } + + /// Gets the assembly's debug (PDB) identity GUID as a string, or if absent. + public string? DebugId => CodeViewGuid?.ToString(); + + /// + /// Gets the PDB checksums recorded in the assembly's debug directory, used to verify a matched + /// PDB. Excluded from JSON; exposes a display-friendly view. + /// + [JsonIgnore] + public List? Checksums { get; init; } + + /// Gets the PDB checksum algorithms recorded for this assembly, for display, or if none. + public List? PdbChecksums => + Checksums is { Count: > 0 } ? Checksums.Select(c => $"{c.Algorithm}:{c.HashHex}").ToList() : null; + + /// + /// Gets a value indicating whether the assembly embeds its own portable PDB. Always evaluated, + /// independent of symbol-package processing. + /// + public bool? HasEmbeddedSymbols { get; init; } + + /// + /// Gets a value indicating whether usable symbols are available for this assembly, either + /// embedded or via a matching symbol-package PDB. + /// + public bool? HasSymbols { get; set; } + + /// + /// Gets a value indicating whether the symbol package contains a PDB for this assembly. + /// when no symbol package was processed. + /// + public bool? HasSymbolPackageSymbols { get; set; } + + /// + /// Gets a value indicating whether the symbol-package PDB matches this assembly's build (by + /// debug GUID). when the symbol package has no PDB for this assembly or + /// was not processed. + /// + public bool? SymbolPackageSymbolsMatch { get; set; } + + /// + /// Gets a value indicating whether the matched symbol-package PDB was verified byte-for-byte + /// against the assembly's recorded PDB checksum. when a checksum matched, + /// when a checksum was available but did not match, and + /// when no checksum could be evaluated. + /// + public bool? SymbolPackageVerifiedByChecksum { get; set; } + + /// Gets the matching PDB's path within the symbol package, if any. + public string? SymbolPackageFile { get; set; } + + /// + /// Creates a report for a native or non-assembly DLL, recording its path, native version info, + /// and marking it as unmanaged. + /// + /// The DLL's path within the package archive. + /// Win32 version-resource information, if any was read. + /// A with set to . + public static BinaryReport Native(string path, NativeVersionInfo? nativeVersion = null) => new() + { + Path = path, + Kind = BinaryKind.Native, + IsManagedAssembly = false, + NativeVersion = nativeVersion, + }; +} diff --git a/tools/PackageValidator/src/NativeVersionReader.cs b/tools/PackageValidator/src/NativeVersionReader.cs new file mode 100644 index 0000000000..56345d86e6 --- /dev/null +++ b/tools/PackageValidator/src/NativeVersionReader.cs @@ -0,0 +1,110 @@ +using System.Diagnostics; +using System.Reflection.PortableExecutable; + +namespace PackageValidator; + +/// +/// Reads Win32 version-resource information from native (unmanaged) binaries. +/// +internal static class NativeVersionReader +{ + /// + /// Reads the Win32 version resource and architecture from a native binary's bytes. + /// + /// The full bytes of the native DLL. + /// + /// A , or if no version-resource or + /// architecture data could be read. + /// + public static NativeVersionInfo? Read(byte[] bytes) + { + string? architecture = ReadArchitecture(bytes); + + string? fileVersion = null; + string? productVersion = null; + string? productName = null; + + // FileVersionInfo needs a real file path; it parses the PE version resource on all + // platforms, so write the bytes to a temp file, read, and clean up. + string tempPath = Path.Combine(Path.GetTempPath(), $"pkgval-{Guid.NewGuid():N}.dll"); + try + { + File.WriteAllBytes(tempPath, bytes); + FileVersionInfo info = FileVersionInfo.GetVersionInfo(tempPath); + fileVersion = NullIfEmpty(info.FileVersion); + productVersion = NullIfEmpty(info.ProductVersion); + productName = NullIfEmpty(info.ProductName); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // Could not stage or read the temp file; fall back to architecture only. + } + finally + { + TryDelete(tempPath); + } + + if (fileVersion is null && productVersion is null && productName is null && architecture is null) + { + return null; + } + + return new NativeVersionInfo + { + FileVersion = fileVersion, + ProductVersion = productVersion, + ProductName = productName, + Architecture = architecture, + }; + } + + /// + /// Reads the processor architecture from a PE image's COFF header. + /// + /// The full bytes of the binary. + /// A short architecture name, or if it cannot be determined. + private static string? ReadArchitecture(byte[] bytes) + { + try + { + using var pe = new PEReader(new MemoryStream(bytes, writable: false)); + return pe.PEHeaders.CoffHeader.Machine switch + { + Machine.Amd64 => "x64", + Machine.I386 => "x86", + Machine.Arm64 => "arm64", + Machine.Arm => "arm", + Machine.IA64 => "ia64", + Machine.Unknown => null, + var other => other.ToString().ToLowerInvariant(), + }; + } + catch (BadImageFormatException) + { + return null; + } + } + + /// Returns for empty or whitespace strings, otherwise the trimmed value. + private static string? NullIfEmpty(string? value) + { + string? trimmed = value?.Trim(); + return string.IsNullOrEmpty(trimmed) ? null : trimmed; + } + + /// Deletes a file, ignoring any failure. + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or System.Security.SecurityException) + { + // Best-effort cleanup. + } + } +} diff --git a/tools/PackageValidator/src/PackageInspector.cs b/tools/PackageValidator/src/PackageInspector.cs new file mode 100644 index 0000000000..c40388929b --- /dev/null +++ b/tools/PackageValidator/src/PackageInspector.cs @@ -0,0 +1,156 @@ +using System.IO.Compression; +using System.Xml.Linq; + +namespace PackageValidator; + +/// +/// Opens a NuGet package and builds a describing its metadata, +/// dependencies, signature state, contained binaries, and symbol-package state. +/// +internal static class PackageInspector +{ + /// The archive entry name of a NuGet package signature. + private const string SignatureEntryName = ".signature.p7s"; + + /// + /// Inspects a NuGet package. + /// + /// The path to the .nupkg file on disk. + /// + /// When , a like-named .snupkg beside the package is located and + /// cross-checked against the assemblies; when , symbol-package processing + /// is skipped (embedded symbols are still evaluated). + /// + /// A populated . + public static PackageReport Inspect(string packagePath, bool processSnupkg) + { + // A .nupkg is a zip archive; open it read-only and walk its entries. + using ZipArchive archive = ZipFile.OpenRead(packagePath); + + // Package-level identity and dependencies come from the embedded .nuspec manifest. + (string? id, string? version, List? dependencyGroups) = ReadNuspec(archive); + + // A NuGet signature is stored as a top-level .signature.p7s archive entry. + bool isSigned = archive.Entries.Any( + e => e.FullName.Equals(SignatureEntryName, StringComparison.OrdinalIgnoreCase)); + + // Inspect every .dll entry regardless of where it sits in the package (lib/, ref/, + // runtimes/, etc.). Native DLLs are tolerated and reported as unmanaged. + var binaries = new List(); + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (!entry.FullName.EndsWith(".dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + binaries.Add(AssemblyInspector.Inspect(entry)); + } + + // Sort by archive path so output is deterministic across runs and platforms. + binaries.Sort((a, b) => string.CompareOrdinal(a.Path, b.Path)); + + // Cross-check the sibling symbol package (if any) against the binaries just discovered. This + // mutates the per-binary symbol fields in place. + SymbolPackageInfo symbolPackage = SymbolResolver.Resolve(packagePath, binaries, processSnupkg); + + return new PackageReport + { + PackageFile = Path.GetFileName(packagePath), + PackageId = id, + PackageVersion = version, + IsSigned = isSigned, + DependencyGroups = dependencyGroups, + Binaries = binaries, + SymbolPackage = symbolPackage, + }; + } + + /// + /// Reads the package id, version, and dependencies from the .nuspec manifest. + /// + /// The opened NuGet package archive. + /// The package id, version, and dependency groups, each of which may be . + private static (string? Id, string? Version, List? Dependencies) ReadNuspec(ZipArchive archive) + { + // The canonical manifest sits at the archive root, so prefer a top-level .nuspec. + ZipArchiveEntry? nuspec = archive.Entries.FirstOrDefault( + e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase) + && !e.FullName.Contains('/')); + + // Fall back to any .nuspec anywhere in the package for non-standard layouts. + nuspec ??= archive.Entries.FirstOrDefault( + e => e.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)); + + if (nuspec is null) + { + return (null, null, null); + } + + using Stream stream = nuspec.Open(); + XDocument doc = XDocument.Load(stream); + + // .nuspec files declare an XML namespace that varies by schema version. Matching on + // LocalName keeps this robust regardless of which namespace URI is in use. + XElement? metadata = doc.Root?.Elements() + .FirstOrDefault(e => e.Name.LocalName == "metadata"); + + string? id = metadata?.Elements().FirstOrDefault(e => e.Name.LocalName == "id")?.Value?.Trim(); + string? version = metadata?.Elements().FirstOrDefault(e => e.Name.LocalName == "version")?.Value?.Trim(); + List? dependencies = ReadDependencies(metadata); + + return (id, version, dependencies); + } + + /// + /// Parses the <dependencies> element of a .nuspec, supporting both the flat + /// (ungrouped) and grouped-by-target-framework layouts. + /// + /// The <metadata> element, or . + /// The parsed dependency groups, or if none are declared. + private static List? ReadDependencies(XElement? metadata) + { + XElement? dependencies = metadata?.Elements() + .FirstOrDefault(e => e.Name.LocalName == "dependencies"); + if (dependencies is null) + { + return null; + } + + var groups = new List(); + + // Flat dependencies (no ) apply to all target frameworks. + List ungrouped = dependencies.Elements() + .Where(e => e.Name.LocalName == "dependency") + .Select(ReadDependency) + .ToList(); + if (ungrouped.Count > 0) + { + groups.Add(new DependencyGroup { TargetFramework = null, Dependencies = ungrouped }); + } + + // Grouped dependencies are scoped to a target framework. + foreach (XElement group in dependencies.Elements().Where(e => e.Name.LocalName == "group")) + { + string? tfm = group.Attribute("targetFramework")?.Value?.Trim(); + List deps = group.Elements() + .Where(e => e.Name.LocalName == "dependency") + .Select(ReadDependency) + .ToList(); + groups.Add(new DependencyGroup { TargetFramework = tfm, Dependencies = deps }); + } + + return groups.Count == 0 ? null : groups; + } + + /// + /// Reads a single <dependency> element. + /// + /// The dependency element. + /// The parsed . + private static DependencyInfo ReadDependency(XElement element) => new() + { + Id = element.Attribute("id")?.Value?.Trim() ?? "(unknown)", + VersionRange = element.Attribute("version")?.Value?.Trim(), + }; +} diff --git a/tools/PackageValidator/src/PackageValidator.csproj b/tools/PackageValidator/src/PackageValidator.csproj new file mode 100644 index 0000000000..68c0a26b92 --- /dev/null +++ b/tools/PackageValidator/src/PackageValidator.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + enable + enable + latest + PackageValidator + PackageValidator + true + + + + + + + + + + + diff --git a/tools/PackageValidator/src/PortablePdb.cs b/tools/PackageValidator/src/PortablePdb.cs new file mode 100644 index 0000000000..6d78b1896a --- /dev/null +++ b/tools/PackageValidator/src/PortablePdb.cs @@ -0,0 +1,188 @@ +using System.Reflection.Metadata; +using System.Security.Cryptography; + +namespace PackageValidator; + +/// +/// Low-level helpers for reading identity and checksum information out of portable PDB byte +/// streams, used to match a symbol-package PDB to the assembly it belongs to. +/// +internal static class PortablePdb +{ + /// The metadata header signature BSJB, marking the start of a portable PDB blob. + private const uint MetadataSignature = 0x424A5342; + + /// The length in bytes of the PDB id (a 16-byte GUID followed by a 4-byte stamp). + private const int PdbIdLength = 20; + + /// + /// Reads the debug GUID from a portable PDB. + /// + /// The full PDB byte content. + /// The PDB's debug GUID, or if the bytes are not a readable portable PDB. + public static Guid? TryReadGuid(byte[] pdb) + { + try + { + using var stream = new MemoryStream(pdb, writable: false); + using MetadataReaderProvider provider = MetadataReaderProvider.FromPortablePdbStream(stream); + MetadataReader reader = provider.GetMetadataReader(); + + // The PDB id begins with the 16-byte GUID that matches the assembly's CodeView GUID; the + // trailing bytes are a timestamp/stamp we do not need here. + DebugMetadataHeader? header = reader.DebugMetadataHeader; + if (header is not null && header.Id.Length >= 16) + { + return new Guid(header.Id.AsSpan(0, 16)); + } + } + catch (BadImageFormatException) + { + // Not a portable PDB (for example a Windows PDB); it cannot be matched by GUID. + } + + return null; + } + + /// + /// Determines whether the given bytes look like a legacy Windows (MSF) PDB rather than a + /// portable PDB. Windows PDBs cannot be matched by the portable-PDB GUID scheme. + /// + /// The PDB byte content. + /// if the bytes begin with the Windows PDB MSF signature. + public static bool IsWindowsPdb(byte[] pdb) + { + // Windows PDBs begin with the ASCII magic "Microsoft C/C++ MSF 7.00\r\n\x1aDS". + ReadOnlySpan magic = "Microsoft C/C++ MSF 7.00"u8; + return pdb.Length >= magic.Length && pdb.AsSpan(0, magic.Length).SequenceEqual(magic); + } + + /// + /// Verifies a portable PDB against a checksum recorded in the owning assembly's debug directory. + /// + /// The full PDB byte content. + /// The checksum (algorithm and expected hash) recorded by the assembly. + /// + /// when the computed hash matches the expected hash; + /// when it does not; when the checksum could not be evaluated (unknown + /// algorithm or an unparseable PDB layout). + /// + /// + /// The recorded checksum is computed over the PDB content with its 20-byte PDB id zeroed, because + /// for deterministic builds the id is derived from the checksum and written afterward. This + /// method therefore locates the #Pdb stream, zeroes the id in a working copy, and hashes + /// that copy with the named algorithm. + /// + public static bool? TryVerifyChecksum(byte[] pdb, PdbChecksum checksum) + { + int idOffset = FindPdbIdOffset(pdb); + if (idOffset < 0 || idOffset + PdbIdLength > pdb.Length) + { + return null; + } + + using HashAlgorithm? algorithm = CreateHashAlgorithm(checksum.Algorithm); + if (algorithm is null) + { + return null; + } + + // Hash a copy in which the PDB id bytes are zeroed, matching how the checksum was produced. + byte[] working = (byte[])pdb.Clone(); + Array.Clear(working, idOffset, PdbIdLength); + byte[] computed = algorithm.ComputeHash(working); + + return computed.AsSpan().SequenceEqual(checksum.Hash); + } + + /// + /// Locates the byte offset of the PDB id (the start of the #Pdb metadata stream) within a + /// portable PDB. + /// + /// The PDB byte content. + /// The offset of the #Pdb stream, or -1 if it cannot be located. + private static int FindPdbIdOffset(byte[] pdb) + { + try + { + using var reader = new BinaryReader(new MemoryStream(pdb, writable: false)); + + if (reader.ReadUInt32() != MetadataSignature) + { + return -1; + } + + reader.ReadUInt16(); // Major version. + reader.ReadUInt16(); // Minor version. + reader.ReadUInt32(); // Reserved. + + int versionLength = reader.ReadInt32(); + reader.BaseStream.Position += versionLength; // Version string, already 4-byte aligned. + + reader.ReadUInt16(); // Flags. + ushort streamCount = reader.ReadUInt16(); + + for (int i = 0; i < streamCount; i++) + { + uint offset = reader.ReadUInt32(); + reader.ReadUInt32(); // Stream size. + string name = ReadStreamName(reader); + if (name == "#Pdb") + { + return (int)offset; + } + } + } + catch (EndOfStreamException) + { + // Truncated or malformed PDB; treat as unlocatable. + } + + return -1; + } + + /// + /// Reads a null-terminated, 4-byte-aligned ASCII metadata stream name from the current position. + /// + /// The reader positioned at the start of a stream name. + /// The decoded stream name. + private static string ReadStreamName(BinaryReader reader) + { + var bytes = new List(16); + int read = 0; + while (true) + { + byte b = reader.ReadByte(); + read++; + if (b == 0) + { + break; + } + bytes.Add(b); + } + + // Stream names are padded with additional null bytes to a 4-byte boundary. + while (read % 4 != 0) + { + reader.ReadByte(); + read++; + } + + return System.Text.Encoding.ASCII.GetString(bytes.ToArray()); + } + + /// + /// Creates a for a PDB checksum algorithm name. + /// + /// The algorithm name as recorded in the debug directory (for example "SHA256"). + /// A new hash algorithm instance, or if the name is not recognized. + private static HashAlgorithm? CreateHashAlgorithm(string algorithm) => + algorithm.ToUpperInvariant() switch + { + "SHA256" => SHA256.Create(), + "SHA384" => SHA384.Create(), + "SHA512" => SHA512.Create(), + "SHA1" => SHA1.Create(), + _ => null, + }; +} diff --git a/tools/PackageValidator/src/Program.cs b/tools/PackageValidator/src/Program.cs new file mode 100644 index 0000000000..9d624e4cbd --- /dev/null +++ b/tools/PackageValidator/src/Program.cs @@ -0,0 +1,381 @@ +using System.CommandLine; +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using System.Text.Json; + +namespace PackageValidator; + +/// +/// Console entry point for the PackageValidator tool, which inspects one or more NuGet packages +/// (.nupkg), reports version, signing, and symbol information for every binary they contain, +/// and applies a set of intrinsic validation rules that callers can use to gate pipelines. +/// +/// +/// +/// Assemblies are inspected purely through metadata without loading them into the runtime. This +/// keeps the tool cross-platform, avoids executing any module initializers, and lets it read +/// assemblies built for target frameworks the tool itself does not run on (for example, .NET +/// Framework assemblies inspected from a .NET host). +/// +/// +/// To exercise the symbol-package (.snupkg) code path with real packages from nuget.org, +/// note that the flat-container feed only serves .nupkg files. The matching symbol package +/// must be downloaded from the gallery's symbol-package endpoint and saved beside the +/// .nupkg with the same base name: +/// +/// # main package +/// https://api.nuget.org/v3-flatcontainer/{idLower}/{version}/{idLower}.{version}.nupkg +/// # sibling symbol package (note: original-cased id, no .snupkg suffix in the URL) +/// https://www.nuget.org/api/v2/symbolpackage/{Id}/{Version} +/// +/// For example, the symbol package for Microsoft.Data.SqlClient 5.2.2 is at +/// https://www.nuget.org/api/v2/symbolpackage/Microsoft.Data.SqlClient/5.2.2; save it as +/// microsoft.data.sqlclient.5.2.2.snupkg next to the .nupkg so the tool finds it. +/// +/// +internal static class Program +{ + /// + /// Parses command-line arguments and dispatches to the inspection and validation logic. + /// + /// The raw command-line arguments. + /// + /// A process exit code: 0 on success with no gating findings, 1 on a + /// runtime/inspection failure, 2 when the --fail-on gate is triggered, and a + /// non-zero System.CommandLine code for argument parsing errors. + /// + private static int Main(string[] args) + { + // One or more paths, each a .nupkg file or a directory to scan for .nupkg files. + var pathsArgument = new Argument("paths") + { + Description = "One or more .nupkg files or directories to scan for .nupkg files.", + Arity = ArgumentArity.OneOrMore, + }; + + // Opt-in switch that selects machine-readable JSON over the default human-readable layout. + var jsonOption = new Option("--json") + { + Description = "Emit machine-readable JSON instead of human-readable text.", + }; + + // Switch that suppresses looking for and processing sibling .snupkg symbol packages. + var noSnupkgOption = new Option("--no-snupkg") + { + Description = "Do not look for or process sibling .snupkg symbol packages.", + }; + + // Repeatable gate: fail the run when a finding matches the given severity or category. + var failOnOption = new Option("--fail-on") + { + Description = "Exit non-zero when a finding matches a severity or category (see Notes).", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + }; + + // Repeatable expected-version assertions, each an optional 'id=' prefix plus a value. + var expectPackageVersionOption = new Option("--expect-package-version") + { + Description = "Confirm the package version (VALUE or id=VALUE; see Notes).", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + }; + var expectFileVersionOption = new Option("--expect-file-version") + { + Description = "Confirm each assembly's file version (VALUE or id=VALUE; see Notes).", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + }; + var expectAssemblyVersionOption = new Option("--expect-assembly-version") + { + Description = "Confirm each assembly's assembly version (VALUE or id=VALUE; see Notes).", + Arity = ArgumentArity.ZeroOrMore, + AllowMultipleArgumentsPerToken = true, + }; + + var rootCommand = new RootCommand( + "Inspect and validate NuGet packages: report versions, signing, and symbols, and apply " + + "intrinsic validation rules.") + { + pathsArgument, + jsonOption, + noSnupkgOption, + failOnOption, + expectPackageVersionOption, + expectFileVersionOption, + expectAssemblyVersionOption, + }; + + // Append a free-form Notes section to the default --help output. + ConfigureHelp(rootCommand); + + rootCommand.SetAction(parseResult => + { + string[] paths = parseResult.GetValue(pathsArgument) ?? []; + bool json = parseResult.GetValue(jsonOption); + bool noSnupkg = parseResult.GetValue(noSnupkgOption); + string[] failOn = parseResult.GetValue(failOnOption) ?? []; + string[] expectPackage = parseResult.GetValue(expectPackageVersionOption) ?? []; + string[] expectFile = parseResult.GetValue(expectFileVersionOption) ?? []; + string[] expectAssembly = parseResult.GetValue(expectAssemblyVersionOption) ?? []; + + VersionExpectations expectations; + try + { + expectations = VersionExpectations.Parse(expectPackage, expectFile, expectAssembly); + } + catch (FormatException ex) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + + return Run(paths, json, !noSnupkg, failOn, expectations); + }); + + return rootCommand.Parse(args).Invoke(); + } + + /// + /// Appends a free-form "Notes" section to the default --help output, documenting the + /// accepted --fail-on values and the VALUE/id=VALUE form of the + /// --expect-* options without crowding their one-line descriptions. + /// + /// The root command whose help is being customized. + private static void ConfigureHelp(RootCommand rootCommand) + { + // HelpBuilder/HelpContext are not public in this System.CommandLine version, so the free-form + // section is appended by wrapping the help action: render the default help, then the Notes. + foreach (Option option in rootCommand.Options) + { + if (option is HelpOption helpOption + && helpOption.Action is SynchronousCommandLineAction inner) + { + helpOption.Action = new NotesHelpAction(inner); + } + } + } + + /// + /// A help action that renders the default help and then writes a free-form Notes section. + /// + private sealed class NotesHelpAction(SynchronousCommandLineAction inner) : SynchronousCommandLineAction + { + /// + public override int Invoke(ParseResult parseResult) + { + int result = inner.Invoke(parseResult); + Console.Out.WriteLine(); + WriteNotes(Console.Out); + return result; + } + } + + /// + /// Writes the free-form Notes help section. + /// + /// The output writer to write the section to. + private static void WriteNotes(TextWriter w) + { + w.WriteLine("Notes:"); + w.WriteLine(); + w.WriteLine(" --fail-on accepts a severity, the token 'any', or a finding category."); + w.WriteLine(" A finding trips the gate when its severity name or its category matches a"); + w.WriteLine(" supplied token; 'any' matches every finding. Exit code is 2 when tripped."); + w.WriteLine(" severities: error warning info"); + w.WriteLine(" special: any"); + w.WriteLine(" categories:"); + foreach (string category in Categories.All) + { + w.WriteLine($" {category}"); + } + w.WriteLine(); + w.WriteLine(" --expect-package-version / --expect-file-version / --expect-assembly-version"); + w.WriteLine(" confirm versions against values the caller already computed. Each value is:"); + w.WriteLine(" VALUE apply the expectation to every package in the run"); + w.WriteLine(" id=VALUE apply only to the package whose id is 'id'"); + w.WriteLine(" A specific id overrides a bare VALUE (wildcard), and each option may be"); + w.WriteLine(" repeated, so a family-wide expectation can coexist with a per-package override:"); + w.WriteLine(" --expect-file-version *=7.1.0.17604 \\"); + w.WriteLine(" --expect-file-version Microsoft.SqlServer.Server=1.1.0.17604"); + w.WriteLine(" Mismatches are reported as unexpected-package-version, unexpected-file-version,"); + w.WriteLine(" or unexpected-assembly-version findings (Error severity)."); + } + + /// + /// Inspects and validates the resolved packages and renders the result. + /// + /// The file and directory paths supplied on the command line. + /// Whether to emit JSON instead of human-readable text. + /// Whether to process sibling symbol packages. + /// The severity/category tokens that trigger a non-zero gate exit. + /// The caller-supplied expected version values to confirm. + /// The process exit code. + private static int Run( + string[] paths, bool json, bool processSnupkg, string[] failOn, VersionExpectations expectations) + { + List packages; + try + { + packages = ResolvePackages(paths); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + + if (packages.Count == 0) + { + Console.Error.WriteLine("Error: no .nupkg files found at the given path(s)."); + return 1; + } + + var reports = new List(); + try + { + foreach (string package in packages) + { + PackageReport report = PackageInspector.Inspect(package, processSnupkg); + Validator.Validate(report, expectations); + reports.Add(report); + } + } + catch (Exception ex) + { + // Surface a concise message rather than a stack trace; the tool is meant to be consumed + // by humans and scripts, not debugged by its callers. + Console.Error.WriteLine($"Error: {ex.Message}"); + return 1; + } + + List crossPackage = Validator.ValidateBatch(reports); + ValidationRun run = BuildRun(reports, crossPackage, failOn); + + if (json) + { + Console.WriteLine(JsonSerializer.Serialize(run, JsonContext.SerializerOptions)); + } + else + { + HumanReporter.Print(run); + } + + return run.Summary.Failed ? 2 : 0; + } + + /// + /// Assembles a from inspected packages, computing summary counts and + /// evaluating the --fail-on gate. + /// + /// The per-package reports. + /// The cross-package findings. + /// The severity/category tokens that trigger a non-zero gate exit. + /// The assembled run. + private static ValidationRun BuildRun( + List reports, List crossPackage, string[] failOn) + { + IEnumerable allFindings = reports + .SelectMany(r => r.Findings ?? Enumerable.Empty()) + .Concat(crossPackage); + + int errors = 0, warnings = 0, infos = 0; + bool failed = false; + var gate = new HashSet(failOn.Select(f => f.Trim().ToLowerInvariant())); + + foreach (Finding finding in allFindings) + { + switch (finding.Severity) + { + case Severity.Error: errors++; break; + case Severity.Warning: warnings++; break; + case Severity.Info: infos++; break; + } + + if (Matches(finding, gate)) + { + failed = true; + } + } + + return new ValidationRun + { + Packages = reports, + CrossPackageFindings = crossPackage.Count == 0 ? null : crossPackage, + Summary = new ValidationSummary + { + PackageCount = reports.Count, + ErrorCount = errors, + WarningCount = warnings, + InfoCount = infos, + Failed = failed, + }, + }; + } + + /// + /// Determines whether a finding matches any of the configured --fail-on tokens. A token + /// matches when it equals the finding's severity name or its category key; the special token + /// any matches every finding. + /// + /// The finding to test. + /// The lowercased set of configured tokens. + /// if the finding should trip the gate. + private static bool Matches(Finding finding, HashSet gate) + { + if (gate.Count == 0) + { + return false; + } + + return gate.Contains("any") + || gate.Contains(finding.Severity.ToString().ToLowerInvariant()) + || gate.Contains(finding.Category); + } + + /// + /// Expands the supplied paths into a sorted, de-duplicated list of .nupkg files. Directory + /// paths are scanned recursively; .snupkg files are excluded. + /// + /// The file and directory paths supplied on the command line. + /// The resolved package file paths. + private static List ResolvePackages(string[] paths) + { + var resolved = new SortedSet(StringComparer.Ordinal); + + // Skip directories the process cannot read rather than aborting the whole scan. + var enumeration = new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + }; + + foreach (string path in paths) + { + if (Directory.Exists(path)) + { + foreach (string file in Directory.EnumerateFiles(path, "*.nupkg", enumeration)) + { + resolved.Add(Path.GetFullPath(file)); + } + } + else if (File.Exists(path)) + { + if (path.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase)) + { + resolved.Add(Path.GetFullPath(path)); + } + else + { + Console.Error.WriteLine($"Warning: skipping non-.nupkg file: {path}"); + } + } + else + { + Console.Error.WriteLine($"Warning: path not found: {path}"); + } + } + + return resolved.ToList(); + } +} diff --git a/tools/PackageValidator/src/SymbolResolver.cs b/tools/PackageValidator/src/SymbolResolver.cs new file mode 100644 index 0000000000..ba52cdc4a3 --- /dev/null +++ b/tools/PackageValidator/src/SymbolResolver.cs @@ -0,0 +1,254 @@ +using System.IO.Compression; + +namespace PackageValidator; + +/// +/// Cross-checks a package's sibling .snupkg symbol package against the assemblies in the +/// main package, matching PDBs by debug GUID, verifying them by checksum where possible, and +/// detecting orphaned or mismatched symbol files. +/// +internal static class SymbolResolver +{ + /// + /// Evaluates symbol coverage for the discovered binaries. Embedded symbols are always assessed; + /// the sibling .snupkg symbol package is additionally cross-checked unless disabled. + /// + /// The path to the inspected .nupkg. + /// The binaries discovered in the package (modified in place). + /// Whether to look for and process a separate symbol package. + /// A describing the symbol state. + public static SymbolPackageInfo Resolve( + string packagePath, List binaries, bool processSnupkg) + { + // Symbols apply only to managed assemblies. Embedded-symbol state was already captured during + // inspection and is always honored, independent of the --no-snupkg switch. + List managed = binaries.Where(b => b.IsManagedAssembly).ToList(); + + string status; + string? file = null; + var matchedPdbs = new HashSet(StringComparer.OrdinalIgnoreCase); + var allPdbPaths = new List(); + + if (!processSnupkg) + { + // The separate symbol package is intentionally not consulted; embedded symbols below are + // still evaluated. + status = "skipped"; + } + else + { + // The symbol package is, by NuGet convention, named identically but with a .snupkg + // extension sitting right beside the .nupkg. + string snupkgPath = Path.ChangeExtension(packagePath, ".snupkg"); + if (!File.Exists(snupkgPath)) + { + status = "missing"; + } + else + { + status = "present"; + file = Path.GetFileName(snupkgPath); + MatchSymbolPackage(snupkgPath, managed, matchedPdbs, allPdbPaths); + } + } + + // Combine the two symbol sources per assembly. + foreach (BinaryReport asm in managed) + { + bool embedded = asm.HasEmbeddedSymbols == true; + bool packageMatches = asm.SymbolPackageSymbolsMatch == true; + asm.HasSymbols = embedded || packageMatches; + } + + // Coverage and match quality are assessed only over implementation assemblies; reference + // assemblies and satellite resource assemblies legitimately ship without symbols. + List implementation = managed.Where(b => b.Kind == BinaryKind.Implementation).ToList(); + bool? allHaveSymbols = implementation.Count == 0 + ? null + : implementation.All(a => a.HasSymbols == true); + + bool? allSymbolsMatch = null; + List? orphans = null; + if (status == "present") + { + List withPackageSymbols = + managed.Where(a => a.HasSymbolPackageSymbols == true).ToList(); + allSymbolsMatch = withPackageSymbols.Count == 0 + ? null + : withPackageSymbols.All(a => a.SymbolPackageSymbolsMatch == true); + + List unmatched = allPdbPaths + .Where(p => !matchedPdbs.Contains(p)) + .OrderBy(p => p, StringComparer.Ordinal) + .ToList(); + orphans = unmatched.Count == 0 ? null : unmatched; + } + + return new SymbolPackageInfo + { + Status = status, + File = file, + AllImplementationAssembliesHaveSymbols = allHaveSymbols, + AllSymbolsMatch = allSymbolsMatch, + OrphanSymbolFiles = orphans, + }; + } + + /// + /// Indexes the PDBs in a symbol package and resolves each managed assembly against them. + /// + /// The path to the sibling symbol package. + /// The managed assemblies to resolve (modified in place). + /// Receives the set of PDB paths that were matched to an assembly. + /// Receives every PDB path found in the symbol package. + private static void MatchSymbolPackage( + string snupkgPath, + List managed, + HashSet matchedPdbs, + List allPdbPaths) + { + using ZipArchive archive = ZipFile.OpenRead(snupkgPath); + + // Index every PDB by its GUID (authoritative identity match) and by its path with the + // extension stripped (fallback to detect a present-but-mismatched symbol file). The bytes are + // retained so a GUID match can be further verified against the assembly's PDB checksum. + var pdbByGuid = new Dictionary(); + var pdbByPathKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + var pdbBytes = new Dictionary(StringComparer.OrdinalIgnoreCase); + var pdbGuidByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (ZipArchiveEntry entry in archive.Entries) + { + if (!entry.FullName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + allPdbPaths.Add(entry.FullName); + pdbByPathKey[StripExtension(entry.FullName)] = entry.FullName; + + byte[] bytes = ReadEntry(entry); + pdbBytes[entry.FullName] = bytes; + + Guid? guid = PortablePdb.TryReadGuid(bytes); + if (guid is Guid value) + { + pdbGuidByPath[entry.FullName] = value; + if (!pdbByGuid.ContainsKey(value)) + { + pdbByGuid[value] = entry.FullName; + } + } + } + + foreach (BinaryReport asm in managed) + { + if (asm.CodeViewGuid is Guid guid && pdbByGuid.TryGetValue(guid, out string? byGuid)) + { + // A symbol-package PDB shares this assembly's debug GUID. + asm.HasSymbolPackageSymbols = true; + asm.SymbolPackageSymbolsMatch = true; + asm.SymbolPackageFile = byGuid; + asm.SymbolPackageVerifiedByChecksum = VerifyChecksum(asm, pdbBytes[byGuid]); + matchedPdbs.Add(byGuid); + } + else if (pdbByPathKey.TryGetValue(StripExtension(asm.Path), out string? byPath)) + { + // A PDB sits where this assembly's symbols should be, but its GUID does not match, + // meaning the symbols belong to a different build. + asm.HasSymbolPackageSymbols = true; + asm.SymbolPackageSymbolsMatch = false; + asm.SymbolPackageFile = byPath; + matchedPdbs.Add(byPath); + } + else + { + asm.HasSymbolPackageSymbols = false; + } + } + + // A package may legitimately ship the same assembly (identical debug GUID) at more than one + // path — e.g. .NET Framework SqlClient appears in both lib/net462 and + // runtimes/win/lib/net462, and the symbol package mirrors it with an identical PDB in each + // spot. Only the first PDB per GUID is indexed above, so every co-located assembly resolves + // to that single PDB and the identical duplicate is never consumed, which would misreport it + // as an orphan. Re-associate any such unmatched duplicate whose co-located assembly is + // present in the package and shares the PDB's debug GUID. + var guidByAssemblyPathKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (BinaryReport asm in managed) + { + if (asm.CodeViewGuid is Guid guid) + { + guidByAssemblyPathKey[StripExtension(asm.Path)] = guid; + } + } + + foreach (string pdbPath in allPdbPaths) + { + if (matchedPdbs.Contains(pdbPath)) + { + continue; + } + + if (pdbGuidByPath.TryGetValue(pdbPath, out Guid pdbGuid) + && guidByAssemblyPathKey.TryGetValue(StripExtension(pdbPath), out Guid asmGuid) + && pdbGuid == asmGuid) + { + matchedPdbs.Add(pdbPath); + } + } + } + + /// + /// Verifies a matched PDB against the assembly's recorded PDB checksums. + /// + /// The assembly whose checksums drive verification. + /// The matched PDB bytes. + /// + /// if any recorded checksum matches, if checksums + /// were available but none matched, and if no checksum could be evaluated. + /// + private static bool? VerifyChecksum(BinaryReport asm, byte[] pdb) + { + if (asm.Checksums is not { Count: > 0 } checksums) + { + return null; + } + + bool anyEvaluated = false; + foreach (PdbChecksum checksum in checksums) + { + bool? result = PortablePdb.TryVerifyChecksum(pdb, checksum); + if (result is true) + { + return true; + } + if (result is false) + { + anyEvaluated = true; + } + } + + // If at least one checksum was evaluated and none matched, the PDB does not correspond to the + // assembly's build despite the GUID; otherwise verification was inconclusive. + return anyEvaluated ? false : null; + } + + /// Reads all bytes of an archive entry into a buffer. + private static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var buffer = new MemoryStream(); + using (Stream stream = entry.Open()) + { + stream.CopyTo(buffer); + } + return buffer.ToArray(); + } + + /// + /// Removes the final file extension from an archive path, yielding a key that aligns a DLL with + /// its co-located PDB. + /// + private static string StripExtension(string path) => + path[..^Path.GetExtension(path).Length]; +} diff --git a/tools/PackageValidator/src/Validator.cs b/tools/PackageValidator/src/Validator.cs new file mode 100644 index 0000000000..7fcd8a9c97 --- /dev/null +++ b/tools/PackageValidator/src/Validator.cs @@ -0,0 +1,346 @@ +namespace PackageValidator; + +/// +/// Stable finding category keys. These are the values accepted by --fail-on. +/// +internal static class Categories +{ + public const string VersionInconsistency = "version-inconsistency"; + public const string MissingSymbols = "missing-symbols"; + public const string SymbolMismatch = "symbol-mismatch"; + public const string SymbolChecksumMismatch = "symbol-checksum-mismatch"; + public const string SymbolOrphan = "symbol-orphan"; + public const string SymbolDuplicate = "symbol-duplicate"; + public const string DelaySigned = "delay-signed"; + public const string Unsigned = "unsigned"; + public const string PackageUnsigned = "package-unsigned"; + public const string DependencyInconsistency = "dependency-inconsistency"; + public const string UnexpectedPackageVersion = "unexpected-package-version"; + public const string UnexpectedFileVersion = "unexpected-file-version"; + public const string UnexpectedAssemblyVersion = "unexpected-assembly-version"; + + /// Gets every known category key. + public static IReadOnlyList All { get; } = + [ + VersionInconsistency, MissingSymbols, SymbolMismatch, SymbolChecksumMismatch, + SymbolOrphan, SymbolDuplicate, DelaySigned, Unsigned, PackageUnsigned, + DependencyInconsistency, UnexpectedPackageVersion, UnexpectedFileVersion, + UnexpectedAssemblyVersion, + ]; +} + +/// +/// Applies the intrinsic validation rules to inspected packages, producing per-package and +/// cross-package instances. +/// +internal static class Validator +{ + /// + /// Validates a single package, attaching its findings to . + /// + /// The inspected package report (modified in place). + /// + /// Optional caller-supplied expected version values to confirm against, or + /// to skip expected-version checks. + /// + public static void Validate(PackageReport report, VersionExpectations? expectations = null) + { + var findings = new List(); + + CheckVersionConsistency(report, findings); + CheckExpectedVersions(report, expectations, findings); + CheckSymbols(report, findings); + CheckSigning(report, findings); + CheckPackageSignature(report, findings); + + report.Findings = findings.Count == 0 ? null : findings; + } + + /// + /// Confirms a package's version, and its assemblies' file and assembly versions, against the + /// caller-supplied expected values. Pointing every package at the same expected value provides + /// inter-package version-match validation and also catches an all-wrong-but-consistent build. + /// + private static void CheckExpectedVersions( + PackageReport report, VersionExpectations? expectations, List findings) + { + if (expectations is null || expectations.IsEmpty) + { + return; + } + + string? expectedPackage = expectations.PackageVersionFor(report.PackageId); + if (expectedPackage is not null + && !string.Equals(report.PackageVersion, expectedPackage, StringComparison.Ordinal)) + { + findings.Add(new Finding + { + Severity = Severity.Error, + Category = Categories.UnexpectedPackageVersion, + Target = report.PackageFile, + Message = + $"package version is '{report.PackageVersion ?? "(none)"}', expected '{expectedPackage}'.", + }); + } + + string? expectedFile = expectations.FileVersionFor(report.PackageId); + string? expectedAssembly = expectations.AssemblyVersionFor(report.PackageId); + + // File and assembly versions are expected to be uniform across every managed assembly the + // package ships, so confirm each one. A missing version is also a mismatch when an + // expectation is supplied, so the assertion does not silently pass. + foreach (BinaryReport asm in report.Binaries.Where(b => b.IsManagedAssembly)) + { + if (expectedFile is not null + && !string.Equals(asm.FileVersion, expectedFile, StringComparison.Ordinal)) + { + findings.Add(new Finding + { + Severity = Severity.Error, + Category = Categories.UnexpectedFileVersion, + Target = asm.Path, + Message = $"file version is '{asm.FileVersion ?? "(none)"}', expected '{expectedFile}'.", + }); + } + + if (expectedAssembly is not null + && !string.Equals(asm.AssemblyVersion, expectedAssembly, StringComparison.Ordinal)) + { + findings.Add(new Finding + { + Severity = Severity.Error, + Category = Categories.UnexpectedAssemblyVersion, + Target = asm.Path, + Message = $"assembly version is '{asm.AssemblyVersion ?? "(none)"}', expected '{expectedAssembly}'.", + }); + } + } + } + + /// + /// Validates relationships that span multiple packages in a single run. + /// + /// All package reports in the run. + /// The cross-package findings, or an empty list when there are none. + public static List ValidateBatch(IReadOnlyList reports) + { + var findings = new List(); + + // Index packages by id so dependencies on in-batch packages can be checked for version + // consistency. + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (PackageReport report in reports) + { + if (report.PackageId is { Length: > 0 } id) + { + byId[id] = report; + } + } + + foreach (PackageReport report in reports) + { + if (report.DependencyGroups is null) + { + continue; + } + + foreach (DependencyGroup group in report.DependencyGroups) + { + foreach (DependencyInfo dependency in group.Dependencies) + { + if (!byId.TryGetValue(dependency.Id, out PackageReport? target) + || target.PackageVersion is not { Length: > 0 } targetVersion) + { + continue; + } + + if (VersionRange.Satisfies(dependency.VersionRange, targetVersion) == false) + { + findings.Add(new Finding + { + Severity = Severity.Warning, + Category = Categories.DependencyInconsistency, + Target = report.PackageFile, + Message = + $"depends on {dependency.Id} {dependency.VersionRange}, but the {dependency.Id} " + + $"package in this run is version {targetVersion}, which is outside that range.", + }); + } + } + } + } + + return findings; + } + + /// + /// Flags assemblies of the same name that carry inconsistent assembly or file versions. + /// + private static void CheckVersionConsistency(PackageReport report, List findings) + { + // Group by assembly name, excluding satellite resource assemblies (which mirror their + // parent and need no independent check). + IEnumerable> groups = report.Binaries + .Where(b => b.IsManagedAssembly && b.Kind != BinaryKind.Satellite && b.AssemblyName is not null) + .GroupBy(b => b.AssemblyName!); + + foreach (IGrouping group in groups) + { + List assemblyVersions = group + .Select(b => b.AssemblyVersion) + .Where(v => v is not null) + .Distinct(StringComparer.Ordinal) + .ToList()!; + if (assemblyVersions.Count > 1) + { + findings.Add(new Finding + { + Severity = Severity.Error, + Category = Categories.VersionInconsistency, + Target = $"{report.PackageFile}:{group.Key}", + Message = + $"assembly '{group.Key}' ships with multiple assembly versions: " + + $"{string.Join(", ", assemblyVersions)}.", + }); + } + + List fileVersions = group + .Select(b => b.FileVersion) + .Where(v => v is not null) + .Distinct(StringComparer.Ordinal) + .ToList()!; + if (fileVersions.Count > 1) + { + findings.Add(new Finding + { + Severity = Severity.Warning, + Category = Categories.VersionInconsistency, + Target = $"{report.PackageFile}:{group.Key}", + Message = + $"assembly '{group.Key}' ships with multiple file versions: " + + $"{string.Join(", ", fileVersions)}.", + }); + } + } + } + + /// + /// Flags missing, mismatched, checksum-failed, duplicated, and orphaned symbols. + /// + private static void CheckSymbols(PackageReport report, List findings) + { + foreach (BinaryReport asm in report.Binaries.Where(b => b.IsManagedAssembly)) + { + // Only implementation assemblies are expected to ship symbols. + if (asm.Kind == BinaryKind.Implementation && asm.HasSymbols != true) + { + findings.Add(new Finding + { + Severity = Severity.Warning, + Category = Categories.MissingSymbols, + Target = asm.Path, + Message = "implementation assembly has no embedded or symbol-package symbols.", + }); + } + + if (asm.HasSymbolPackageSymbols == true && asm.SymbolPackageSymbolsMatch == false) + { + findings.Add(new Finding + { + Severity = Severity.Error, + Category = Categories.SymbolMismatch, + Target = asm.Path, + Message = + $"symbol-package PDB '{asm.SymbolPackageFile}' does not match the assembly build (GUID differs).", + }); + } + + if (asm.SymbolPackageVerifiedByChecksum == false) + { + findings.Add(new Finding + { + Severity = Severity.Error, + Category = Categories.SymbolChecksumMismatch, + Target = asm.Path, + Message = + $"symbol-package PDB '{asm.SymbolPackageFile}' matched by GUID but failed checksum verification.", + }); + } + + if (asm.HasEmbeddedSymbols == true && asm.HasSymbolPackageSymbols == true) + { + findings.Add(new Finding + { + Severity = Severity.Info, + Category = Categories.SymbolDuplicate, + Target = asm.Path, + Message = "symbols are present both embedded and in the symbol package.", + }); + } + } + + if (report.SymbolPackage.OrphanSymbolFiles is { Count: > 0 } orphans) + { + foreach (string orphan in orphans) + { + findings.Add(new Finding + { + Severity = Severity.Warning, + Category = Categories.SymbolOrphan, + Target = orphan, + Message = "symbol-package PDB does not correspond to any assembly in the package.", + }); + } + } + } + + /// + /// Flags delay-signed and unsigned implementation assemblies. + /// + private static void CheckSigning(PackageReport report, List findings) + { + foreach (BinaryReport asm in report.Binaries.Where( + b => b.IsManagedAssembly && b.Kind == BinaryKind.Implementation)) + { + switch (asm.SigningStatus) + { + case PackageValidator.SigningStatus.DelaySigned: + findings.Add(new Finding + { + Severity = Severity.Warning, + Category = Categories.DelaySigned, + Target = asm.Path, + Message = "assembly carries a public key but is delay-signed (strong-name flag not set).", + }); + break; + + case PackageValidator.SigningStatus.Unsigned: + findings.Add(new Finding + { + Severity = Severity.Info, + Category = Categories.Unsigned, + Target = asm.Path, + Message = "assembly is not strong-name signed.", + }); + break; + } + } + } + + /// + /// Flags a package that carries no NuGet author/repository signature. + /// + private static void CheckPackageSignature(PackageReport report, List findings) + { + if (!report.IsSigned) + { + findings.Add(new Finding + { + Severity = Severity.Info, + Category = Categories.PackageUnsigned, + Target = report.PackageFile, + Message = "package is not signed (no .signature.p7s entry).", + }); + } + } +} diff --git a/tools/PackageValidator/src/VersionExpectations.cs b/tools/PackageValidator/src/VersionExpectations.cs new file mode 100644 index 0000000000..960d31a63a --- /dev/null +++ b/tools/PackageValidator/src/VersionExpectations.cs @@ -0,0 +1,88 @@ +namespace PackageValidator; + +/// +/// Caller-supplied expected version values, keyed by package id, used to confirm that inspected +/// packages and their assemblies carry the versions the build was supposed to produce. This enables +/// inter-package version-match validation: pointing every package at the same expected value proves +/// they agree, and also catches the case where every package is consistently wrong. +/// +internal sealed class VersionExpectations +{ + /// The wildcard id that matches any package when no specific id is supplied. + public const string Wildcard = "*"; + + private readonly Dictionary _package = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _file = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _assembly = new(StringComparer.OrdinalIgnoreCase); + + /// Gets a value indicating whether any expectation was supplied. + public bool IsEmpty => _package.Count == 0 && _file.Count == 0 && _assembly.Count == 0; + + /// + /// Parses the raw --expect-* option values into a . + /// + /// The --expect-package-version specs. + /// The --expect-file-version specs. + /// The --expect-assembly-version specs. + /// The parsed expectations. + /// Thrown when a spec has an empty value. + public static VersionExpectations Parse( + string[] packageVersion, string[] fileVersion, string[] assemblyVersion) + { + var expectations = new VersionExpectations(); + Fill(expectations._package, packageVersion, "--expect-package-version"); + Fill(expectations._file, fileVersion, "--expect-file-version"); + Fill(expectations._assembly, assemblyVersion, "--expect-assembly-version"); + return expectations; + } + + /// Gets the expected package version for an id, or if none applies. + public string? PackageVersionFor(string? id) => Resolve(_package, id); + + /// Gets the expected file version for an id, or if none applies. + public string? FileVersionFor(string? id) => Resolve(_file, id); + + /// Gets the expected assembly version for an id, or if none applies. + public string? AssemblyVersionFor(string? id) => Resolve(_assembly, id); + + /// + /// Parses each [id=]value spec into a map entry, defaulting a missing id to the wildcard. + /// + private static void Fill(Dictionary map, string[] specs, string optionName) + { + foreach (string raw in specs) + { + string spec = raw.Trim(); + if (spec.Length == 0) + { + continue; + } + + // Package ids never contain '=' and version values never do either, so the first '=' + // unambiguously separates an optional id from the value. + int eq = spec.IndexOf('='); + string id = eq < 0 ? Wildcard : spec[..eq].Trim(); + string value = eq < 0 ? spec : spec[(eq + 1)..].Trim(); + + if (value.Length == 0) + { + throw new FormatException($"{optionName}: missing value in '{raw}'."); + } + + map[id.Length == 0 ? Wildcard : id] = value; + } + } + + /// + /// Resolves an expectation for a package id, preferring a specific id over the wildcard. + /// + private static string? Resolve(Dictionary map, string? id) + { + if (id is not null && map.TryGetValue(id, out string? specific)) + { + return specific; + } + + return map.TryGetValue(Wildcard, out string? wildcard) ? wildcard : null; + } +} diff --git a/tools/PackageValidator/src/VersionRange.cs b/tools/PackageValidator/src/VersionRange.cs new file mode 100644 index 0000000000..58cc2c21a8 --- /dev/null +++ b/tools/PackageValidator/src/VersionRange.cs @@ -0,0 +1,264 @@ +using System.Globalization; + +namespace PackageValidator; + +/// +/// A minimal evaluator for NuGet dependency version ranges, sufficient to check whether a concrete +/// package version falls inside a declared range. Comparison follows SemVer 2.0 precedence rules, +/// including prerelease ordering; build metadata is ignored (it does not affect precedence). +/// +internal static class VersionRange +{ + /// + /// Determines whether a concrete version satisfies a NuGet version range. + /// + /// The declared version range (for example "1.2.3" or "[1.0.0,2.0.0)"). + /// The concrete version to test. + /// + /// or when the relationship can be evaluated, and + /// when the range or version cannot be parsed (caller should not treat + /// this as a failure). + /// + public static bool? Satisfies(string? range, string version) + { + if (string.IsNullOrWhiteSpace(range)) + { + return null; + } + + SemanticVersion? target = Parse(version); + if (target is null) + { + return null; + } + + range = range.Trim(); + + // A bare version (no brackets) is a minimum-inclusive bound in NuGet semantics. + if (range[0] != '[' && range[0] != '(') + { + SemanticVersion? min = Parse(range); + return min is null ? null : Compare(target, min) >= 0; + } + + bool minInclusive = range[0] == '['; + bool maxInclusive = range[^1] == ']'; + string inner = range[1..^1]; + + int comma = inner.IndexOf(','); + if (comma < 0) + { + // "[1.0.0]" denotes an exact version. + SemanticVersion? exact = Parse(inner); + return exact is null ? null : Compare(target, exact) == 0; + } + + string lower = inner[..comma].Trim(); + string upper = inner[(comma + 1)..].Trim(); + + if (lower.Length > 0) + { + SemanticVersion? min = Parse(lower); + if (min is null) + { + return null; + } + int cmp = Compare(target, min); + if (cmp < 0 || (cmp == 0 && !minInclusive)) + { + return false; + } + } + + if (upper.Length > 0) + { + SemanticVersion? max = Parse(upper); + if (max is null) + { + return null; + } + int cmp = Compare(target, max); + if (cmp > 0 || (cmp == 0 && !maxInclusive)) + { + return false; + } + } + + return true; + } + + /// + /// Parses a version string into its numeric release components and optional prerelease + /// identifiers, discarding any build-metadata suffix. + /// + /// The version string. + /// The parsed version, or if the release portion is unparseable. + private static SemanticVersion? Parse(string version) + { + string text = version.Trim(); + + // Build metadata ("+...") does not affect precedence; drop it first. + int plus = text.IndexOf('+'); + if (plus >= 0) + { + text = text[..plus]; + } + + // A prerelease ("-...") follows the release; split on the first hyphen. + string release; + string[]? prerelease = null; + int hyphen = text.IndexOf('-'); + if (hyphen >= 0) + { + release = text[..hyphen]; + string pre = text[(hyphen + 1)..]; + // An empty prerelease (a trailing hyphen) is treated as no prerelease. + prerelease = pre.Length == 0 ? null : pre.Split('.'); + } + else + { + release = text; + } + + string[] parts = release.Split('.', StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) + { + return null; + } + + var numbers = new int[parts.Length]; + for (int i = 0; i < parts.Length; i++) + { + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out numbers[i])) + { + return null; + } + } + + return new SemanticVersion(numbers, prerelease); + } + + /// + /// Compares two versions using SemVer 2.0 precedence: release components first (missing trailing + /// components treated as zero), then prerelease precedence (a prerelease version is lower than + /// the same release without one). + /// + /// The first version. + /// The second version. + /// A negative, zero, or positive value per standard comparison semantics. + private static int Compare(SemanticVersion a, SemanticVersion b) + { + int length = Math.Max(a.Release.Length, b.Release.Length); + for (int i = 0; i < length; i++) + { + int left = i < a.Release.Length ? a.Release[i] : 0; + int right = i < b.Release.Length ? b.Release[i] : 0; + int cmp = left.CompareTo(right); + if (cmp != 0) + { + return cmp; + } + } + + return ComparePrerelease(a.Prerelease, b.Prerelease); + } + + /// + /// Compares two prerelease identifier sets per SemVer 2.0 precedence rules. + /// + /// The first version's prerelease identifiers, or if none. + /// The second version's prerelease identifiers, or if none. + /// A negative, zero, or positive value per standard comparison semantics. + private static int ComparePrerelease(string[]? a, string[]? b) + { + // A version without a prerelease has higher precedence than one with a prerelease. + if (a is null && b is null) + { + return 0; + } + if (a is null) + { + return 1; + } + if (b is null) + { + return -1; + } + + int shared = Math.Min(a.Length, b.Length); + for (int i = 0; i < shared; i++) + { + int cmp = CompareIdentifier(a[i], b[i]); + if (cmp != 0) + { + return cmp; + } + } + + // When all shared identifiers are equal, the larger set has higher precedence. + return a.Length.CompareTo(b.Length); + } + + /// + /// Compares two prerelease identifiers: numeric identifiers compare numerically and rank lower + /// than alphanumeric identifiers, which compare by ASCII order. + /// + private static int CompareIdentifier(string a, string b) + { + bool aNumeric = IsNumeric(a); + bool bNumeric = IsNumeric(b); + + if (aNumeric && bNumeric) + { + if (long.TryParse(a, NumberStyles.None, CultureInfo.InvariantCulture, out long na) + && long.TryParse(b, NumberStyles.None, CultureInfo.InvariantCulture, out long nb)) + { + return na.CompareTo(nb); + } + + // Fall back for identifiers too large for long: compare by significant length, then text. + string ta = a.TrimStart('0'); + string tb = b.TrimStart('0'); + int byLength = ta.Length.CompareTo(tb.Length); + return byLength != 0 ? byLength : string.CompareOrdinal(ta, tb); + } + + // Numeric identifiers always have lower precedence than alphanumeric ones. + if (aNumeric) + { + return -1; + } + if (bNumeric) + { + return 1; + } + + return string.CompareOrdinal(a, b); + } + + /// Determines whether an identifier consists solely of ASCII digits. + private static bool IsNumeric(string value) + { + if (value.Length == 0) + { + return false; + } + + foreach (char c in value) + { + if (!char.IsAsciiDigit(c)) + { + return false; + } + } + + return true; + } + + /// + /// A parsed semantic version: numeric release components plus optional prerelease identifiers. + /// + /// The numeric release components (for example 1.2.3). + /// The dot-separated prerelease identifiers, or for a release version. + private sealed record SemanticVersion(int[] Release, string[]? Prerelease); +} diff --git a/tools/PackageValidator/test/AssemblyInspectorTests.cs b/tools/PackageValidator/test/AssemblyInspectorTests.cs new file mode 100644 index 0000000000..1c9704a9e2 --- /dev/null +++ b/tools/PackageValidator/test/AssemblyInspectorTests.cs @@ -0,0 +1,86 @@ +using System.Security.Cryptography; +using Xunit; + +namespace PackageValidator.Tests; + +/// +/// Tests for , focused on the public-key-token derivation that turns +/// a strong-name public key into its 8-byte token. +/// +public class AssemblyInspectorTests +{ + /// + /// Verifies that an assembly with no public key (empty or null blob) yields no token, since such + /// an assembly is not strong-name signed. + /// + [Fact] + public void ComputePublicKeyToken_returns_null_for_empty_key() + { + Assert.Null(AssemblyInspector.ComputePublicKeyToken([])); + Assert.Null(AssemblyInspector.ComputePublicKeyToken(null!)); + } + + /// + /// Verifies the token derivation contract: the low 8 bytes of SHA-1(publicKey), emitted in + /// reverse (little-endian) order as lowercase hex. + /// + [Fact] + public void ComputePublicKeyToken_is_reversed_sha1_tail() + { + // Arrange: an arbitrary public-key blob plus the token computed independently from the + // documented contract (reversed last 8 bytes of its SHA-1). + byte[] key = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + byte[] hash = SHA1.HashData(key); + var expected = new byte[8]; + for (int i = 0; i < 8; i++) + { + expected[i] = hash[hash.Length - 1 - i]; + } + string expectedHex = Convert.ToHexStringLower(expected); + + // Act + Assert: the inspector must produce that same hex token. + Assert.Equal(expectedHex, AssemblyInspector.ComputePublicKeyToken(key)); + } +} + +/// +/// Tests for , covering portable-PDB GUID reading and the detection of +/// legacy Windows (MSF) PDBs that cannot be matched by GUID. +/// +public class PortablePdbTests +{ + /// + /// Verifies that the legacy Windows PDB magic ("Microsoft C/C++ MSF 7.00") is recognized so such + /// PDBs can be reported rather than silently treated as portable. + /// + [Fact] + public void IsWindowsPdb_detects_msf_signature() + { + // Arrange: bytes that begin with the Windows PDB MSF signature. + byte[] windows = System.Text.Encoding.ASCII.GetBytes("Microsoft C/C++ MSF 7.00\r\n"); + + // Act + Assert. + Assert.True(PortablePdb.IsWindowsPdb(windows)); + } + + /// + /// Verifies that non-Windows-PDB content (including the portable-PDB "BSJB" signature and empty + /// input) is not misidentified as a Windows PDB. + /// + [Fact] + public void IsWindowsPdb_rejects_other_content() + { + byte[] portableSignature = [0x42, 0x53, 0x4A, 0x42]; // "BSJB" - the portable PDB / metadata magic + Assert.False(PortablePdb.IsWindowsPdb(portableSignature)); + Assert.False(PortablePdb.IsWindowsPdb([])); + } + + /// + /// Verifies that bytes which are not a readable portable PDB yield no GUID rather than throwing. + /// + [Fact] + public void TryReadGuid_returns_null_for_non_pdb() + { + Assert.Null(PortablePdb.TryReadGuid([0, 1, 2, 3])); + } +} diff --git a/tools/PackageValidator/test/BinaryClassifierTests.cs b/tools/PackageValidator/test/BinaryClassifierTests.cs new file mode 100644 index 0000000000..6754931e7f --- /dev/null +++ b/tools/PackageValidator/test/BinaryClassifierTests.cs @@ -0,0 +1,43 @@ +using Xunit; + +namespace PackageValidator.Tests; + +/// +/// Tests for , which maps a DLL's package path to the role it plays +/// (implementation, reference, satellite, or other). +/// +public class BinaryClassifierTests +{ + /// + /// Verifies the path-to-kind mapping for the common NuGet layouts: lib/ and + /// runtimes/.../lib/ are implementation, ref/ is reference, culture + /// *.resources.dll are satellite, and anything else is other. + /// + /// The DLL path within the package. + /// The expected name. + [Theory] + [InlineData("lib/net8.0/Foo.dll", "Implementation")] + [InlineData("runtimes/win/lib/net462/Foo.dll", "Implementation")] + [InlineData("ref/net8.0/Foo.dll", "Reference")] + [InlineData("lib/net8.0/de/Foo.resources.dll", "Satellite")] + [InlineData("lib/net8.0/fr/Foo.resources.dll", "Satellite")] + [InlineData("build/Foo.dll", "Other")] + [InlineData("tools/Foo.dll", "Other")] + public void Classify_maps_paths_to_kinds(string path, string expected) + { + // Compare by name (the enum is internal, so the public test signature cannot expose it). + Assert.Equal(expected, BinaryClassifier.Classify(path).ToString()); + } + + /// + /// Verifies that a satellite resource assembly is classified as + /// even though it also lives under lib/, since the .resources.dll suffix is the + /// stronger signal. + /// + [Fact] + public void Classify_satellite_takes_precedence_over_lib() + { + // The path is under lib/ but the name ends in .resources.dll, which must win. + Assert.Equal("Satellite", BinaryClassifier.Classify("lib/net8.0/es/Foo.resources.dll").ToString()); + } +} diff --git a/tools/PackageValidator/test/PackageValidator.Test.csproj b/tools/PackageValidator/test/PackageValidator.Test.csproj new file mode 100644 index 0000000000..5d3d40056c --- /dev/null +++ b/tools/PackageValidator/test/PackageValidator.Test.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + latest + PackageValidator.Test + false + true + true + + + + + + + + + + + diff --git a/tools/PackageValidator/test/ValidatorTests.cs b/tools/PackageValidator/test/ValidatorTests.cs new file mode 100644 index 0000000000..713805f921 --- /dev/null +++ b/tools/PackageValidator/test/ValidatorTests.cs @@ -0,0 +1,318 @@ +using System.Linq; +using Xunit; + +namespace PackageValidator.Tests; + +/// +/// Tests for , the intrinsic rules engine. Each test builds a synthetic +/// so a single rule can be exercised in isolation, then asserts on the +/// findings the validator attaches. +/// +public class ValidatorTests +{ + /// + /// Creates a symbol-package descriptor in the "skipped" state, so symbol rules stay inert and a + /// test can focus on the rule under examination. + /// + /// A with status "skipped". + private static SymbolPackageInfo SkippedSymbols() => new() + { + Status = "skipped", + }; + + /// + /// Builds a minimal, otherwise-valid signed package report wrapping the given binaries, so a + /// test only has to specify the binaries relevant to the rule it exercises. + /// + /// The binaries the package contains. + /// A populated . + private static PackageReport Package(params BinaryReport[] binaries) => new() + { + PackageFile = "Test.1.0.0.nupkg", + PackageId = "Test", + PackageVersion = "1.0.0", + IsSigned = true, + Binaries = binaries.ToList(), + SymbolPackage = SkippedSymbols(), + }; + + /// + /// Verifies that two assemblies of the same name carrying different assembly versions raise an + /// Error-severity version-inconsistency finding. + /// + [Fact] + public void Flags_inconsistent_assembly_versions_as_error() + { + // Arrange: the same assembly under two TFMs with conflicting assembly versions. + PackageReport report = Package( + new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + FileVersion = "1.0.0.0", + HasSymbols = true, + }, + new BinaryReport + { + Path = "lib/net462/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "2.0.0.0", + FileVersion = "1.0.0.0", + HasSymbols = true, + }); + + // Act. + Validator.Validate(report); + + // Assert: an Error version-inconsistency finding is produced. + Assert.NotNull(report.Findings); + Assert.Contains(report.Findings!, f => + f.Category == Categories.VersionInconsistency && f.Severity == Severity.Error); + } + + /// + /// Verifies that same-named assemblies with matching assembly versions but differing file + /// versions raise a Warning-severity version-inconsistency finding (a weaker signal than an + /// assembly-version conflict). + /// + [Fact] + public void Flags_inconsistent_file_versions_as_warning() + { + // Arrange: identical assembly versions, but the file-version revision differs between TFMs. + PackageReport report = Package( + new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + FileVersion = "1.0.0.0", + HasSymbols = true, + }, + new BinaryReport + { + Path = "lib/net462/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + FileVersion = "1.0.0.17603", + HasSymbols = true, + }); + + // Act. + Validator.Validate(report); + + // Assert: the mismatch surfaces as a Warning rather than an Error. + Assert.Contains(report.Findings!, f => + f.Category == Categories.VersionInconsistency && f.Severity == Severity.Warning); + } + + /// + /// Verifies that the missing-symbols rule applies only to implementation assemblies: a reference + /// assembly without symbols is exempt, so exactly one finding (for the implementation DLL) is + /// produced. + /// + [Fact] + public void Flags_missing_symbols_only_for_implementation_assemblies() + { + // Arrange: an implementation and a reference assembly, both lacking symbols. + PackageReport report = Package( + new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + HasSymbols = false, + }, + new BinaryReport + { + Path = "ref/net8.0/Foo.dll", + Kind = BinaryKind.Reference, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + HasSymbols = false, + }); + + // Act. + Validator.Validate(report); + + // Assert: only the implementation assembly is flagged; the reference assembly is exempt. + List missing = report.Findings! + .Where(f => f.Category == Categories.MissingSymbols) + .ToList(); + Assert.Single(missing); + Assert.Equal("lib/net8.0/Foo.dll", missing[0].Target); + } + + /// + /// Verifies that a delay-signed implementation assembly (public key present but the strong-name + /// flag unset) raises a Warning-severity delay-signed finding. + /// + [Fact] + public void Flags_delay_signed_assembly() + { + // Arrange: an assembly that carries a public key but reports DelaySigned. + PackageReport report = Package(new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + PublicKeyToken = "23ec7fc2d6eaa4a5", + SigningStatus = SigningStatus.DelaySigned, + HasSymbols = true, + }); + + // Act. + Validator.Validate(report); + + // Assert. + Assert.Contains(report.Findings!, f => + f.Category == Categories.DelaySigned && f.Severity == Severity.Warning); + } + + /// + /// Verifies that a symbol-package PDB which matches by GUID but fails checksum verification raises + /// an Error-severity checksum-mismatch finding. + /// + [Fact] + public void Flags_symbol_checksum_mismatch_as_error() + { + // Arrange: a GUID-matched symbol whose checksum verification was evaluated and failed. + PackageReport report = Package(new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + HasSymbols = true, + HasSymbolPackageSymbols = true, + SymbolPackageSymbolsMatch = true, + SymbolPackageVerifiedByChecksum = false, + SymbolPackageFile = "lib/net8.0/Foo.pdb", + }); + + // Act. + Validator.Validate(report); + + // Assert. + Assert.Contains(report.Findings!, f => + f.Category == Categories.SymbolChecksumMismatch && f.Severity == Severity.Error); + } + + /// + /// Verifies that a package without a NuGet signature raises a package-unsigned finding. + /// + [Fact] + public void Flags_unsigned_package() + { + // Arrange: a valid package, then re-create it as unsigned (IsSigned is init-only). + PackageReport report = Package(new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + HasSymbols = true, + }); + report = new PackageReport + { + PackageFile = report.PackageFile, + PackageId = report.PackageId, + PackageVersion = report.PackageVersion, + IsSigned = false, + Binaries = report.Binaries, + SymbolPackage = report.SymbolPackage, + }; + + // Act. + Validator.Validate(report); + + // Assert. + Assert.Contains(report.Findings!, f => f.Category == Categories.PackageUnsigned); + } + + /// + /// Verifies the cross-package rule: a dependency on an in-batch package whose actual version + /// falls outside the declared range raises a dependency-inconsistency finding. + /// + [Fact] + public void Batch_flags_dependency_version_inconsistency() + { + // Arrange: a consumer requiring Lib >= 2.0.0, but the Lib in the same batch is only 1.0.0. + var consumer = new PackageReport + { + PackageFile = "Consumer.1.0.0.nupkg", + PackageId = "Consumer", + PackageVersion = "1.0.0", + IsSigned = true, + Binaries = [], + SymbolPackage = SkippedSymbols(), + DependencyGroups = + [ + new DependencyGroup + { + TargetFramework = "net8.0", + Dependencies = [new DependencyInfo { Id = "Lib", VersionRange = "[2.0.0,)" }], + }, + ], + }; + var lib = new PackageReport + { + PackageFile = "Lib.1.0.0.nupkg", + PackageId = "Lib", + PackageVersion = "1.0.0", + IsSigned = true, + Binaries = [], + SymbolPackage = SkippedSymbols(), + }; + + // Act: validate the two packages together so the cross-package rule can correlate them. + List findings = Validator.ValidateBatch([consumer, lib]); + + // Assert. + Assert.Contains(findings, f => f.Category == Categories.DependencyInconsistency); + } + + /// + /// Verifies that a consistent, signed package with matching versions and symbols produces no + /// Error-severity findings (the informational/clean baseline). + /// + [Fact] + public void Clean_package_produces_no_error_findings() + { + // Arrange: a signed, strong-named assembly with symbols and consistent versions. + PackageReport report = Package(new BinaryReport + { + Path = "lib/net8.0/Foo.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "Foo", + AssemblyVersion = "1.0.0.0", + FileVersion = "1.0.0.0", + PublicKeyToken = "23ec7fc2d6eaa4a5", + SigningStatus = SigningStatus.Signed, + HasSymbols = true, + }); + + // Act. + Validator.Validate(report); + + // Assert: nothing rises to Error severity (info-level findings such as package-unsigned may + // still exist, but none here since the package is signed). + Assert.DoesNotContain(report.Findings ?? [], f => f.Severity == Severity.Error); + } +} diff --git a/tools/PackageValidator/test/VersionExpectationsTests.cs b/tools/PackageValidator/test/VersionExpectationsTests.cs new file mode 100644 index 0000000000..e2d4327a49 --- /dev/null +++ b/tools/PackageValidator/test/VersionExpectationsTests.cs @@ -0,0 +1,181 @@ +using System.Linq; +using Xunit; + +namespace PackageValidator.Tests; + +/// +/// Tests for the --expect-* assertions wired through and +/// : confirming package, file, and assembly versions against caller-supplied +/// expected values, including wildcard/per-id precedence and missing-version handling. +/// +public class VersionExpectationsTests +{ + /// + /// Creates a symbol-package descriptor in the "skipped" state so symbol rules stay inert. + /// + /// A with status "skipped". + private static SymbolPackageInfo SkippedSymbols() => new() { Status = "skipped" }; + + /// + /// Builds a single-assembly package report with the given identity and versions, used to model a + /// family member under expected-version assertions. + /// + /// The package id. + /// The package version. + /// The contained assembly's file version. + /// The contained assembly's assembly version. + /// A populated . + private static PackageReport Family(string id, string pkgVer, string fileVer, string asmVer) => new() + { + PackageFile = $"{id}.{pkgVer}.nupkg", + PackageId = id, + PackageVersion = pkgVer, + IsSigned = true, + Binaries = + [ + new BinaryReport + { + Path = "lib/net8.0/X.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "X", + AssemblyVersion = asmVer, + FileVersion = fileVer, + HasSymbols = true, + }, + ], + SymbolPackage = SkippedSymbols(), + }; + + /// + /// Verifies a wildcard file-version expectation: the matching package passes while a package + /// whose file version differs is flagged with an Error-severity finding. + /// + [Fact] + public void Wildcard_file_version_flags_mismatch() + { + // Arrange: a wildcard expectation, one conforming package and one that does not. + VersionExpectations exp = VersionExpectations.Parse([], ["7.1.0.17604"], []); + PackageReport ok = Family("MDS", "7.1.0", "7.1.0.17604", "7.0.0.0"); + PackageReport bad = Family("Ext", "7.1.0", "7.1.0.0", "7.0.0.0"); + + // Act. + Validator.Validate(ok, exp); + Validator.Validate(bad, exp); + + // Assert: only the non-conforming package raises an unexpected-file-version finding. + Assert.DoesNotContain(ok.Findings ?? [], f => f.Category == Categories.UnexpectedFileVersion); + Assert.Contains(bad.Findings!, f => + f.Category == Categories.UnexpectedFileVersion && f.Severity == Severity.Error); + } + + /// + /// Verifies that a per-id expectation overrides the wildcard, so a package matching its specific + /// expected value is not flagged even though it differs from the wildcard value. + /// + [Fact] + public void Specific_id_overrides_wildcard() + { + // Arrange: the family expects .17604, but SqlServer is allowed its own value. + VersionExpectations exp = VersionExpectations.Parse( + [], ["*=7.1.0.17604", "Microsoft.SqlServer.Server=1.1.0.17604"], []); + PackageReport sqlServer = Family("Microsoft.SqlServer.Server", "1.1.0", "1.1.0.17604", "1.0.0.0"); + + // Act. + Validator.Validate(sqlServer, exp); + + // Assert: the per-id expectation is honored, so no mismatch is reported. + Assert.DoesNotContain(sqlServer.Findings ?? [], f => f.Category == Categories.UnexpectedFileVersion); + } + + /// + /// Verifies that an assembly with no AssemblyFileVersion is flagged (reported as + /// (none)) against a file-version expectation, so the assertion never silently passes when + /// the version attribute is absent. + /// + [Fact] + public void Missing_file_version_is_flagged_against_expectation() + { + // Arrange: an expectation plus an implementation assembly that carries no file version. + VersionExpectations exp = VersionExpectations.Parse([], ["7.1.0.17604"], []); + var report = new PackageReport + { + PackageFile = "MDS.7.1.0.nupkg", + PackageId = "MDS", + PackageVersion = "7.1.0", + IsSigned = true, + Binaries = + [ + new BinaryReport + { + Path = "lib/net8.0/X.dll", + Kind = BinaryKind.Implementation, + IsManagedAssembly = true, + AssemblyName = "X", + AssemblyVersion = "7.0.0.0", + FileVersion = null, + HasSymbols = true, + }, + ], + SymbolPackage = SkippedSymbols(), + }; + + // Act. + Validator.Validate(report, exp); + + // Assert: the missing version is reported as an Error mentioning "(none)". + Assert.Contains(report.Findings!, f => + f.Category == Categories.UnexpectedFileVersion + && f.Severity == Severity.Error + && f.Message.Contains("(none)")); + } + + /// + /// Verifies that a package whose version differs from the expected package version is flagged. + /// + [Fact] + public void Package_version_mismatch_is_flagged() + { + // Arrange: expect a -pr build, but the package is a -ci build. + VersionExpectations exp = VersionExpectations.Parse(["7.1.0-preview1-pr17604"], [], []); + PackageReport bad = Family("MDS", "7.1.0-preview1-ci17621", "7.1.0.17621", "7.0.0.0"); + + // Act. + Validator.Validate(bad, exp); + + // Assert. + Assert.Contains(bad.Findings!, f => f.Category == Categories.UnexpectedPackageVersion); + } + + /// + /// Verifies that when no expectations are supplied, the expected-version rules are inert: the + /// expectations are empty and no unexpected-version findings are produced. + /// + [Fact] + public void Empty_expectations_produce_no_expected_version_findings() + { + // Arrange: no expectations at all. + VersionExpectations exp = VersionExpectations.Parse([], [], []); + Assert.True(exp.IsEmpty); + + // Act: validate a package whose versions would mismatch were any expectation set. + PackageReport p = Family("MDS", "7.1.0", "7.1.0.0", "7.0.0.0"); + Validator.Validate(p, exp); + + // Assert: none of the expected-version categories appear. + Assert.DoesNotContain(p.Findings ?? [], f => + f.Category is Categories.UnexpectedFileVersion + or Categories.UnexpectedPackageVersion + or Categories.UnexpectedAssemblyVersion); + } + + /// + /// Verifies that an id= spec with an empty value is rejected at parse time rather than + /// being interpreted as a real expectation. + /// + [Fact] + public void Parse_rejects_empty_value() + { + Assert.Throws(() => VersionExpectations.Parse([], ["MDS="], [])); + } +} diff --git a/tools/PackageValidator/test/VersionRangeTests.cs b/tools/PackageValidator/test/VersionRangeTests.cs new file mode 100644 index 0000000000..64282d1a2d --- /dev/null +++ b/tools/PackageValidator/test/VersionRangeTests.cs @@ -0,0 +1,107 @@ +using Xunit; + +namespace PackageValidator.Tests; + +/// +/// Tests for , the SemVer 2.0-aware evaluator that decides whether a +/// concrete version falls inside a NuGet dependency version range. +/// +public class VersionRangeTests +{ + /// + /// Verifies the bracket/interval grammar for release-only versions: bare minimum bounds, exact + /// pins, and inclusive/exclusive interval endpoints. + /// + /// The declared version range. + /// The concrete version under test. + /// Whether is expected to satisfy . + [Theory] + [InlineData("1.0.0", "1.0.0", true)] // bare version is a minimum-inclusive bound + [InlineData("1.0.0", "1.5.0", true)] + [InlineData("1.0.0", "0.9.0", false)] + [InlineData("[1.0.0]", "1.0.0", true)] // exact + [InlineData("[1.0.0]", "1.0.1", false)] + [InlineData("[1.0.0,2.0.0)", "1.5.0", true)] + [InlineData("[1.0.0,2.0.0)", "2.0.0", false)] // upper exclusive + [InlineData("[1.0.0,2.0.0]", "2.0.0", true)] // upper inclusive + [InlineData("(1.0.0,)", "1.0.0", false)] // lower exclusive + [InlineData("(1.0.0,)", "1.0.1", true)] + [InlineData("(,2.0.0]", "1.0.0", true)] + [InlineData("(,2.0.0]", "2.0.1", false)] + public void Satisfies_evaluates_ranges(string range, string version, bool expected) + { + // Each row supplies its own inputs (arrange) and expected outcome (assert); the call is the act. + Assert.Equal(expected, VersionRange.Satisfies(range, version)); + } + + /// + /// Verifies SemVer precedence between a release and its prerelease: a prerelease ranks below the + /// matching release. + /// + [Fact] + public void Satisfies_release_outranks_prerelease_of_same_version() + { + // A prerelease is lower precedence than its release, so it falls below a min-inclusive bound. + Assert.Equal(false, VersionRange.Satisfies("1.0.0", "1.0.0-alpha")); + + // A release satisfies a prerelease lower bound (release > prerelease of the same version). + Assert.Equal(true, VersionRange.Satisfies("1.0.0-alpha", "1.0.0")); + + // A higher release still satisfies even when it carries a prerelease tag, because its release + // components (1.2.3) already exceed the bound. + Assert.Equal(true, VersionRange.Satisfies("1.0.0", "1.2.3-preview.1")); + } + + /// + /// Verifies prerelease-identifier precedence: numeric identifiers compare numerically and rank + /// below alphanumeric ones, and a larger identifier set outranks a smaller prefix. + /// + /// The declared version range. + /// The concrete version under test. + /// Whether is expected to satisfy . + [Theory] + [InlineData("[1.0.0-alpha,1.0.0]", "1.0.0-beta", true)] // alpha < beta < release + [InlineData("[1.0.0-alpha.1,)", "1.0.0-alpha.2", true)] // numeric identifier ordering + [InlineData("[1.0.0-alpha.2,)", "1.0.0-alpha.1", false)] + [InlineData("[1.0.0-alpha,)", "1.0.0-alpha.1", true)] // larger identifier set ranks higher + [InlineData("[1.0.0-alpha,)", "1.0.0-1", false)] // numeric ranks below alphanumeric + public void Satisfies_orders_prerelease_identifiers(string range, string version, bool expected) + { + Assert.Equal(expected, VersionRange.Satisfies(range, version)); + } + + /// + /// Verifies that build metadata (the + suffix) is ignored, matching SemVer/NuGet, so it + /// never changes whether a version satisfies a range. + /// + [Fact] + public void Satisfies_ignores_build_metadata() + { + // "+abc123" / "+build.5" must not affect precedence, so the version still matches an exact + // pin and a minimum bound respectively. + Assert.Equal(true, VersionRange.Satisfies("[1.0.0]", "1.0.0+abc123")); + Assert.Equal(true, VersionRange.Satisfies("1.0.0", "1.0.0+build.5")); + } + + /// + /// Verifies that an absent range is treated as "no constraint" by returning + /// (the caller must not treat this as a failure). + /// + [Fact] + public void Satisfies_returns_null_for_empty_range() + { + Assert.Null(VersionRange.Satisfies(null, "1.0.0")); + Assert.Null(VersionRange.Satisfies("", "1.0.0")); + } + + /// + /// Verifies that versions with different component counts compare correctly, with missing + /// trailing components treated as zero (for example 1.0 equals 1.0.0.0). + /// + [Fact] + public void Satisfies_treats_missing_components_as_zero() + { + Assert.Equal(true, VersionRange.Satisfies("1.0", "1.0.0.0")); + Assert.Equal(true, VersionRange.Satisfies("[1.0]", "1.0.0")); + } +}