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"));
+ }
+}