From 579f8cc80bb61d33e517cfcad03646af099cd60b Mon Sep 17 00:00:00 2001 From: Alessandro Giorgetti Date: Thu, 15 Oct 2026 12:52:14 +0200 Subject: [PATCH] Fix generator symbol package layout and validate shipping symbols --- .github/workflows/ci.yml | 13 + DECISIONS.md | 9 + IMPLEMENTATION_PLAN.md | 8 + Mammoth.LiteMapper.sln | 17 + Mammoth.LiteMapper.slnx | 3 + STATUS.md | 9 + docs/USAGE.md | 2 + publish-nuget.ps1 | 17 +- .../Mammoth.LiteMapper.Generator.csproj | 11 +- .../Mammoth.LiteMapper.Packaging.Tests.csproj | 1 + .../SymbolPackageValidationTests.cs | 300 ++++++++++++++++++ .../SymbolPublicationGateTests.cs | 83 +++++ .../WorkflowNativeFailureTests.cs | 2 + ...ammoth.LiteMapper.PackageValidation.csproj | 6 + .../Program.cs | 39 +++ .../SymbolPackageValidator.cs | 264 +++++++++++++++ 16 files changed, 776 insertions(+), 8 deletions(-) create mode 100644 tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs create mode 100644 tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs create mode 100644 tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj create mode 100644 tools/Mammoth.LiteMapper.PackageValidation/Program.cs create mode 100644 tools/Mammoth.LiteMapper.PackageValidation/SymbolPackageValidator.cs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index caef965..e7e59c2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -328,6 +328,19 @@ jobs: throw 'Packages contain Roslyn runtime assets.' } + - name: Validate package symbols + shell: pwsh + run: | + $version = '${{ steps.gitversion.outputs.semver }}' + $feed = (Resolve-Path '${{ env.PACKAGE_OUTPUT }}').Path + $symbolPairs = @() + foreach ($packageId in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) { + $symbolPairs += Join-Path $feed "$packageId.$version.nupkg" + $symbolPairs += Join-Path $feed "$packageId.$version.snupkg" + } + dotnet run --project tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj -c Release --no-restore -- @symbolPairs + if ($LASTEXITCODE -ne 0) { throw "Package symbol validation failed with exit code $LASTEXITCODE." } + - name: Validate final package consumers shell: pwsh run: | diff --git a/DECISIONS.md b/DECISIONS.md index 5ad9449..6f3ba9c 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -1,5 +1,14 @@ # Mammoth.LiteMapper Decision Log +## Generator symbol package layout (2026-10-15) + +- Contract: specification sections 4.1, 22.19 and 24.6 require analyzer isolation, valid shipping packages and portable reproducible symbols. No public API or mapping semantic change is needed. +- Cause: the published 4.0.1 generator DLL lives under analyzers/dotnet/cs, while automatic debug-output packing placed its matching PDB under lib/netstandard2.0. Removing BuiltProjectOutputGroupOutput did not remove the separate debug-symbol output group. +- Packing: disable automatic generator build-output packing and explicitly pack its DLL and PDB at the analyzer path. The PDB is also included beside the analyzer DLL in the regular generator package; neither becomes a runtime lib/ref asset. +- Validation: use one standalone .NET validator in package tests, release CI before artifact promotion, and the manual publication script, including -SkipValidation and dry runs. Check metadata, path pairing, portable format, PE/PDB association and the compiler checksum with the PDB ID zeroed. Determinism and checksum manifests remain required but do not prove symbol validity. +- Coverage follows NuGet's supplied-PDB-to-assembly rule; it does not require a duplicate PDB for every packaged DLL. Shipping tests require the Abstractions and primary runtime PDBs plus the standalone generator's analyzer PDB. The primary package's duplicated analyzer DLL does not need a second copy of those globally indexed symbols. +- Recovery: the original release PDB matches the immutable 4.0.1 DLL. A separately prepared snupkg changes only that entry's path and can be submitted independently at the existing ID/version. Preparation and PR creation do not authorize publication. The PR targets develop because 4.0.1 is published and its release branch was removed. + ## Issues #55 and #56: legal target accessors and required construction (2026-10-15) - Contract: specification sections 6.4, 9.1, 10.4-10.5, 14.1, 16.1 and 20.1-20.3 already define legal target assignment and mandatory required-member diagnostics. No normative change or new API is needed. diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index cbf88c7..06f0a42 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -1,5 +1,13 @@ # Mammoth.LiteMapper Implementation Plan +## Generator symbol package repair (2026-10-15) + +- Scope: user-authorized packaging maintenance on an isolated branch from develop after the 4.0.1 release. Prepare corrected 4.0.1 symbols separately, then open a draft PR against develop; the release branch has been removed. This is direct maintenance, not a claimed tracker item. +- Test-first: validate every shipping nupkg/snupkg pair, reproducing the generator PDB path mismatch before changing its pack configuration. Reject mismatched PE/PDB identity, compiler checksum, nonportable symbols, and inconsistent package metadata. +- Implementation: disable automatic generator build-output packing and explicitly place its DLL and portable PDB together under analyzers/dotnet/cs. Reuse one archive/identity/checksum validator in tests, release CI and the manual publishing script before publication. +- Validation: focused regressions, full correctness and packaging coverage excluding local benchmark-launching tests, independent review, and terminal exact-head Windows/Linux and Roslyn CI. Preserve existing CI gates, skill content, and performance tests. +- Boundary: corrected symbols are delivered for a separate human upload. No NuGet push, package replacement/unlisting, tag, merge, credential changes, machine settings, or local timing benchmarks are authorized. + ## Release 4.0.1 corrections: issues #55 and #56 (2026-10-15) - Scope: user-authorized combined repair on an isolated branch from release/4.0.1 at d7d8c3bbc578470ddb9403c2a9064b96d8d87b25, with a draft PR back to that release branch. diff --git a/Mammoth.LiteMapper.sln b/Mammoth.LiteMapper.sln index ca020fe..cda0c99 100644 --- a/Mammoth.LiteMapper.sln +++ b/Mammoth.LiteMapper.sln @@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.Benchmar EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.ProcessFixture", "tests\Mammoth.LiteMapper.Packaging.Tests\ProcessFixture\Mammoth.LiteMapper.ProcessFixture.csproj", "{A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{E63B4D72-2B11-4B36-99B4-489B586E2321}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mammoth.LiteMapper.PackageValidation", "tools\Mammoth.LiteMapper.PackageValidation\Mammoth.LiteMapper.PackageValidation.csproj", "{C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -189,6 +193,18 @@ Global {A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}.Release|x64.Build.0 = Release|Any CPU {A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}.Release|x86.ActiveCfg = Release|Any CPU {A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29}.Release|x86.Build.0 = Release|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x64.ActiveCfg = Debug|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x64.Build.0 = Debug|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x86.ActiveCfg = Debug|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Debug|x86.Build.0 = Debug|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|Any CPU.Build.0 = Release|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x64.ActiveCfg = Release|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x64.Build.0 = Release|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x86.ActiveCfg = Release|Any CPU + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -206,5 +222,6 @@ Global {77429349-3F3A-4DDA-B18A-D148612D07DD} = {5D20AA90-6969-D8BD-9DCD-8634F4692FDA} {9FAC9778-94D0-4ADC-B2FE-6DBC5715A38E} = {66320409-64EC-F7C5-3DEF-65E7510DAAD1} {A8B5221B-4D45-4E98-83B9-C4F5A0FD6E29} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {C5F34DF8-E046-4D3A-B7F7-47E54D56C3F0} = {E63B4D72-2B11-4B36-99B4-489B586E2321} EndGlobalSection EndGlobal diff --git a/Mammoth.LiteMapper.slnx b/Mammoth.LiteMapper.slnx index 6cf3275..bdb4250 100644 --- a/Mammoth.LiteMapper.slnx +++ b/Mammoth.LiteMapper.slnx @@ -24,4 +24,7 @@ + + + diff --git a/STATUS.md b/STATUS.md index 006368b..f28f7dd 100644 --- a/STATUS.md +++ b/STATUS.md @@ -1,5 +1,14 @@ # Mammoth.LiteMapper Status +## Generator symbol packaging checkpoint (2026-10-15) + +- Scope: direct user-authorized maintenance from develop after 4.0.1 publication. The old release branch is absent. The isolated fix branch targets develop and stops at draft PR/human review. +- Reproduction: the retained tag-run generator snupkg has its PDB under lib/netstandard2.0 while the published DLL is under analyzers/dotnet/cs. All six release-artifact hashes were verified. Published nupkg payloads match the retained release packages, and the generator PDB matches the published DLL's portable identity and compiler checksum. +- Red-first: the shipping-pair regression failed on that exact orphan path while ten fixture checks passed. Three publication-gate tests failed before script integration. After the fix, the focused symbol/gate group passed 14 tests and the release/native safeguard group passed 60 tests. Additional no-PDB and partial-symbol controls document NuGet's supplied-PDB validation direction. +- Implementation: explicit generator analyzer DLL/PDB packing replaces the build-output-removal target. The shared nonshipping validator checks package metadata, archive paths, portability, PE/PDB association and compiler checksum. Release CI and the manual publishing script validate every shipping pair before promotion or push, including skip-validation and dry-run paths. +- Validation: Release solution build passes with no compiler errors; two existing generator-test dependency audit warnings remain. Local generator correctness 1,083, runtime five and integration one pass, with allocation measurements and the benchmark launcher excluded from local execution. Package-suite results and terminal exact-head Windows/Linux/Roslyn CI evidence are recorded in the draft PR. Independent read-only review found no remaining actionable issue. +- Recovery boundary: corrected 4.0.1 symbols preserve every entry's payload and change only the generator PDB archive path. They passed validation against the actual published nupkg and were delivered separately for human re-upload. No NuGet upload, unlist, tag, merge, credential/system change, skill/performance-test edit or local timing benchmark was performed. + ## Release 4.0.1 issues #55 and #56 (2026-10-15) - Base: remote release/4.0.1 independently verified unchanged at d7d8c3bbc578470ddb9403c2a9064b96d8d87b25. Work uses the isolated feature/4.0.1-mapping-corrections branch; exactly two nonduplicate bug issues were created and claimed through AgentStack. diff --git a/docs/USAGE.md b/docs/USAGE.md index e2da38a..f0fbf42 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -1005,6 +1005,8 @@ Windows and Linux are supported. A `netstandard2.0` library runs inside an appli The primary `Mammoth.LiteMapper` package is the normal install path. `Mammoth.LiteMapper.Generator` is the generator implementation package and is not the normal consumer install route. +The generator DLL is an analyzer asset under `analyzers/dotnet/cs`, outside runtime `lib` and `ref` assets. Portable symbols must match the exact compiled DLL and use the same archive-relative path in the corresponding `.snupkg`, replacing `.dll` with `.pdb`. Symbol-package corrections do not require changing an application's package reference. + ## Analyzer options The generator recognizes these optional build properties for diagnostics and development. In the consuming project, set each value and export it with `CompilerVisibleProperty` so Roslyn passes it to the generator: diff --git a/publish-nuget.ps1 b/publish-nuget.ps1 index 1e81778..6fac7ab 100644 --- a/publish-nuget.ps1 +++ b/publish-nuget.ps1 @@ -175,8 +175,21 @@ if ($unexpected.Count -ne 0 -or $missing.Count -ne 0 -or $artifacts.Count -ne $e throw "Expected exactly $($expectedArtifacts.Count) versioned package artifacts for $version; missing: $($missing -join ', '); unexpected: $($unexpected.Name -join ', ')." } -foreach ($packageId in $packageIds) { - $artifactName = "$packageId.$version.nupkg" +$symbolValidator = 'tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj' +if ($SkipValidation) { + & dotnet restore $symbolValidator + Assert-NativeSuccess 'Restore package symbol validator' +} +$symbolPairs = @() +foreach ($packageId in $packageIds) { + $symbolPairs += Join-Path $resolvedOutput "$packageId.$version.nupkg" + $symbolPairs += Join-Path $resolvedOutput "$packageId.$version.snupkg" +} +& dotnet run --project $symbolValidator -c Release --no-restore -- @symbolPairs +Assert-NativeSuccess 'Validate package symbols' + +foreach ($packageId in $packageIds) { + $artifactName = "$packageId.$version.nupkg" $artifactPath = Join-Path $resolvedOutput $artifactName if ($DryRun) { Write-Host "Dry run: would publish $artifactPath to $Source" diff --git a/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj b/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj index afdfa03..35cc701 100644 --- a/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj +++ b/src/Mammoth.LiteMapper.Generator/Mammoth.LiteMapper.Generator.csproj @@ -2,6 +2,7 @@ netstandard2.0 true + false true Source generator package for Mammoth.LiteMapper. true @@ -14,11 +15,9 @@ Pack="true" PackagePath="analyzers/dotnet/cs" Visible="false" /> + - - - - - - diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj b/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj index 8766b16..bd07d57 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/Mammoth.LiteMapper.Packaging.Tests.csproj @@ -7,6 +7,7 @@ + diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs new file mode 100644 index 0000000..6531b56 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPackageValidationTests.cs @@ -0,0 +1,300 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Mammoth.LiteMapper.PackageValidation; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Packaging.Tests +{ + [TestClass] + public sealed class SymbolPackageValidationTests + { + [TestMethod] + public void MatchingPortableSymbolsAreAccepted() + { + using var fixture = SymbolPackageFixture.Create(pdbPath: "LIB/NET10.0/mammoth.litemapper.packaging.tests.PDB"); + + AssertValid(fixture); + } + + [TestMethod] + public void SymbolPackageMustContainAPdb() + { + using var fixture = SymbolPackageFixture.Create(includePdb: false); + + AssertInvalid(fixture, "does not contain a PDB"); + } + + [TestMethod] + public void AdditionalAssembliesDoNotRequireDuplicateSymbols() + { + using var fixture = SymbolPackageFixture.Create(addAssemblyWithoutSymbols: true); + + AssertValid(fixture); + } + + [TestMethod] + public void PdbMustUseTheAssemblyArchiveRelativePath() + { + using var fixture = SymbolPackageFixture.Create(pdbPath: "symbols/Mammoth.LiteMapper.Packaging.Tests.pdb"); + + AssertInvalid(fixture, "same archive-relative path"); + } + + [TestMethod] + public void PdbIdentityMustMatchTheAssembly() + { + using var fixture = SymbolPackageFixture.Create(pdbMutation: ChangePortablePdbIdentity); + + AssertInvalid(fixture, "does not match the PE identity"); + } + + [TestMethod] + public void PdbChecksumMustMatchEvenWhenTheIdentityMatches() + { + using var fixture = SymbolPackageFixture.Create(pdbMutation: AppendByteWithoutChangingIdentity); + + AssertInvalid(fixture, "does not match the compiler checksum"); + } + + [TestMethod] + public void PdbMustBePortable() + { + using var fixture = SymbolPackageFixture.Create(pdbMutation: RemovePortableSignature); + + AssertInvalid(fixture, "is not portable"); + } + + [TestMethod] + public void AssemblyMustContainACompilerPdbChecksum() + { + using var fixture = SymbolPackageFixture.Create(peMutation: RemovePdbChecksumEntry); + + AssertInvalid(fixture, "does not contain a compiler PDB checksum"); + } + + [TestMethod] + [DataRow("Other.Package", "1.2.3", "package ID")] + [DataRow("Test.Package", "1.2.4", "package version")] + public void SymbolIdentityMustMatchThePrimaryPackage(string symbolId, string symbolVersion, string expectedError) + { + using var fixture = SymbolPackageFixture.Create(symbolId: symbolId, symbolVersion: symbolVersion); + + AssertInvalid(fixture, expectedError); + } + + [TestMethod] + public void SymbolNuspecMustDeclareSymbolsPackage() + { + using var fixture = SymbolPackageFixture.Create(includeSymbolsPackageType: false); + + AssertInvalid(fixture, "SymbolsPackage"); + } + + [TestMethod] + public void SymbolPackageRejectsUnsupportedEntryExtensions() + { + using var fixture = SymbolPackageFixture.Create(addUnsupportedEntry: true); + + AssertInvalid(fixture, "unsupported extension"); + } + + [TestMethod] + public void ShippingSymbolPackagesMatchTheirBinaries() + { + var feed = Milestone14PackagingAndAotTests.CreateFeed(); + Milestone14PackagingAndAotTests.PackAll(feed); + + foreach (var packageId in new[] + { + "Mammoth.LiteMapper.Abstractions", + "Mammoth.LiteMapper.Generator", + "Mammoth.LiteMapper" + }) + { + var package = Path.Combine(feed, packageId + ".1.0.0.nupkg"); + var symbols = Path.Combine(feed, packageId + ".1.0.0.snupkg"); + var errors = SymbolPackageValidator.Validate(package, symbols); + Assert.AreEqual(0, errors.Count, packageId + Environment.NewLine + string.Join(Environment.NewLine, errors)); + } + + AssertSymbolEntry(feed, "Mammoth.LiteMapper.Abstractions", "lib/netstandard2.0/Mammoth.LiteMapper.Abstractions.pdb"); + AssertSymbolEntry(feed, "Mammoth.LiteMapper.Generator", "analyzers/dotnet/cs/Mammoth.LiteMapper.Generator.pdb"); + AssertSymbolEntry(feed, "Mammoth.LiteMapper", "lib/netstandard2.0/Mammoth.LiteMapper.pdb"); + } + + private static void AssertSymbolEntry(string feed, string packageId, string entryPath) + { + using var archive = ZipFile.OpenRead(Path.Combine(feed, packageId + ".1.0.0.snupkg")); + Assert.IsTrue( + archive.Entries.Any(entry => string.Equals(entry.FullName, entryPath, StringComparison.OrdinalIgnoreCase)), + packageId + " is missing " + entryPath + "."); + } + + private static void AssertValid(SymbolPackageFixture fixture) + { + var errors = SymbolPackageValidator.Validate(fixture.PackagePath, fixture.SymbolPackagePath); + Assert.AreEqual(0, errors.Count, string.Join(Environment.NewLine, errors)); + } + + private static void AssertInvalid(SymbolPackageFixture fixture, string expectedError) + { + var errors = SymbolPackageValidator.Validate(fixture.PackagePath, fixture.SymbolPackagePath); + Assert.AreNotEqual(0, errors.Count); + StringAssert.Contains(string.Join(Environment.NewLine, errors), expectedError); + } + + private static byte[] ChangePortablePdbIdentity(byte[] bytes) + { + using var stream = new MemoryStream(bytes, false); + using var provider = MetadataReaderProvider.FromPortablePdbStream(stream, MetadataStreamOptions.LeaveOpen); + var offset = provider.GetMetadataReader().DebugMetadataHeader!.IdStartOffset; + var changed = bytes.ToArray(); + changed[offset] ^= 0x01; + return changed; + } + + private static byte[] AppendByteWithoutChangingIdentity(byte[] bytes) + { + var changed = new byte[bytes.Length + 1]; + bytes.CopyTo(changed, 0); + changed[changed.Length - 1] = 0x01; + return changed; + } + + private static byte[] RemovePortableSignature(byte[] bytes) + { + var changed = bytes.ToArray(); + changed[0] = 0; + return changed; + } + + private static byte[] RemovePdbChecksumEntry(byte[] bytes) + { + var changed = bytes.ToArray(); + using var stream = new MemoryStream(changed, true); + using var reader = new PEReader(stream, PEStreamOptions.LeaveOpen); + var directory = reader.PEHeaders.PEHeader!.DebugTableDirectory; + var section = reader.PEHeaders.SectionHeaders.Single(header => + directory.RelativeVirtualAddress >= header.VirtualAddress && + directory.RelativeVirtualAddress < header.VirtualAddress + Math.Max(header.VirtualSize, header.SizeOfRawData)); + var tableOffset = section.PointerToRawData + directory.RelativeVirtualAddress - section.VirtualAddress; + const int debugDirectoryEntrySize = 28; + var checksumType = (int)DebugDirectoryEntryType.PdbChecksum; + + for (var offset = tableOffset; offset < tableOffset + directory.Size; offset += debugDirectoryEntrySize) + { + if (BitConverter.ToInt32(changed, offset + 12) == checksumType) + { + Array.Clear(changed, offset + 12, sizeof(int)); + return changed; + } + } + + Assert.Fail("The packaging test assembly does not contain a compiler PDB checksum entry."); + return changed; + } + + private sealed class SymbolPackageFixture : IDisposable + { + private SymbolPackageFixture(string directory) + { + Directory = directory; + PackagePath = Path.Combine(directory, "Test.Package.1.2.3.nupkg"); + SymbolPackagePath = Path.Combine(directory, "Test.Package.1.2.3.snupkg"); + } + + internal string Directory { get; } + + internal string PackagePath { get; } + + internal string SymbolPackagePath { get; } + + internal static SymbolPackageFixture Create( + string symbolId = "Test.Package", + string symbolVersion = "1.2.3", + string pdbPath = "lib/net10.0/Mammoth.LiteMapper.Packaging.Tests.pdb", + Func? pdbMutation = null, + Func? peMutation = null, + bool includeSymbolsPackageType = true, + bool includePdb = true, + bool addAssemblyWithoutSymbols = false, + bool addUnsupportedEntry = false) + { + var directory = Path.Combine(Path.GetTempPath(), "MammothLiteMapperSymbolTests", Guid.NewGuid().ToString("N")); + System.IO.Directory.CreateDirectory(directory); + var fixture = new SymbolPackageFixture(directory); + var assemblyPath = typeof(SymbolPackageValidationTests).Assembly.Location; + var pdbFilePath = Path.ChangeExtension(assemblyPath, ".pdb"); + var peBytes = File.ReadAllBytes(assemblyPath); + var pdbBytes = File.ReadAllBytes(pdbFilePath); + if (peMutation != null) + { + peBytes = peMutation(peBytes); + } + + if (pdbMutation != null) + { + pdbBytes = pdbMutation(pdbBytes); + } + + using (var package = ZipFile.Open(fixture.PackagePath, ZipArchiveMode.Create)) + { + WriteText(package, "Test.Package.nuspec", Nuspec("Test.Package", "1.2.3", false)); + WriteBytes(package, "lib/net10.0/Mammoth.LiteMapper.Packaging.Tests.dll", peBytes); + if (addAssemblyWithoutSymbols) + { + WriteBytes(package, "analyzers/dotnet/cs/Additional.dll", peBytes); + } + } + + using (var symbols = ZipFile.Open(fixture.SymbolPackagePath, ZipArchiveMode.Create)) + { + WriteText(symbols, "Test.Package.nuspec", Nuspec(symbolId, symbolVersion, includeSymbolsPackageType)); + if (includePdb) + { + WriteBytes(symbols, pdbPath, pdbBytes); + } + + if (addUnsupportedEntry) + { + WriteText(symbols, "content/readme.txt", "unsupported"); + } + } + + return fixture; + } + + public void Dispose() + { + if (System.IO.Directory.Exists(Directory)) + { + System.IO.Directory.Delete(Directory, true); + } + } + + private static string Nuspec(string id, string version, bool symbolsPackage) + { + var packageType = symbolsPackage + ? "" + : string.Empty; + return "" + id + "" + version + "" + packageType + ""; + } + + private static void WriteText(ZipArchive archive, string path, string value) + { + using var writer = new StreamWriter(archive.CreateEntry(path).Open()); + writer.Write(value); + } + + private static void WriteBytes(ZipArchive archive, string path, byte[] value) + { + using var stream = archive.CreateEntry(path).Open(); + stream.Write(value, 0, value.Length); + } + } + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs new file mode 100644 index 0000000..e9d3c24 --- /dev/null +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/SymbolPublicationGateTests.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using System.Text; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Mammoth.LiteMapper.Packaging.Tests +{ + [TestClass] + public sealed class SymbolPublicationGateTests + { + [TestMethod] + public void ReleaseWorkflowValidatesSymbolsBeforeConsumerValidationAndArtifactUpload() + { + var workflow = File.ReadAllText(Repository.Path(".github/workflows/ci.yml")); + var validation = workflow.IndexOf(" - name: Validate package symbols", StringComparison.Ordinal); + Assert.IsTrue(validation >= 0, "Release packages must pass symbol validation before promotion."); + Assert.IsTrue(validation < workflow.IndexOf(" - name: Validate final package consumers", StringComparison.Ordinal)); + Assert.IsTrue(validation < workflow.IndexOf(" - name: Upload validated packages", StringComparison.Ordinal)); + var block = workflow.Substring(validation, workflow.IndexOf(" - name: Validate final package consumers", StringComparison.Ordinal) - validation); + StringAssert.Contains(block, "Mammoth.LiteMapper.PackageValidation.csproj"); + StringAssert.Contains(block, "'Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper'"); + StringAssert.Contains(block, "if ($LASTEXITCODE -ne 0)"); + } + + [TestMethod] + public void ManualPublishingValidatesAllSymbolPairsEvenWhenBroadValidationIsSkipped() + { + var script = File.ReadAllText(Repository.Path("publish-nuget.ps1")); + var validation = script.IndexOf("& dotnet run --project $symbolValidator", StringComparison.Ordinal); + Assert.IsTrue(validation >= 0, "Symbol validation must be mandatory even with -SkipValidation."); + var artifactCheck = script.IndexOf("Expected exactly", StringComparison.Ordinal); + var publish = script.LastIndexOf("foreach ($packageId in $packageIds)", StringComparison.Ordinal); + Assert.IsTrue(artifactCheck < validation && validation < publish); + StringAssert.Contains(script, "Assert-NativeSuccess 'Validate package symbols'"); + StringAssert.Contains(script, "$symbolPairs += Join-Path $resolvedOutput \"$packageId.$version.snupkg\""); + } + + [TestMethod] + public void ManualPublishingStopsBeforePushWhenSymbolValidationFails() + { + var relativeOutput = Path.Combine("artifacts", "symbols-gate-" + Guid.NewGuid().ToString("N")); + var output = Repository.Path(relativeOutput); + Directory.CreateDirectory(output); + var marker = Path.Combine(output, "push.marker"); + var command = @" +$ErrorActionPreference = 'Stop' +function dotnet { + $global:LASTEXITCODE = 0 + $joined = $args -join ' ' + if ($joined -like '*gitversion*') { + '{""SemVer"":""1.2.3"",""AssemblySemVer"":""1.2.3.0"",""AssemblySemFileVer"":""1.2.3.0"",""InformationalVersion"":""1.2.3""}' + } elseif ($joined -like 'pack *') { + $index = [Array]::IndexOf($args, '-o') + foreach ($id in @('Mammoth.LiteMapper.Abstractions', 'Mammoth.LiteMapper.Generator', 'Mammoth.LiteMapper')) { + foreach ($extension in @('nupkg', 'snupkg')) { + Set-Content -LiteralPath (Join-Path $args[$index + 1] ($id + '.1.2.3.' + $extension)) -Value 'mock artifact' + } + } + } elseif ($joined -like 'run *PackageValidation*') { + $global:LASTEXITCODE = 23 + } elseif ($joined -like 'nuget push *') { + Set-Content -LiteralPath '" + Escape(marker) + @"' -Value 'pushed' + } +} +& '" + Escape(Repository.Path("publish-nuget.ps1")) + @"' -ApiKey 'local-mock-key' -Source local -OutputDirectory '" + Escape(relativeOutput) + @"' -SkipValidation -DryRun:$false +"; + try + { + var encoded = Convert.ToBase64String(Encoding.Unicode.GetBytes(command)); + var result = TestProcess.Run("pwsh", "-NoProfile -NonInteractive -EncodedCommand " + encoded, Repository.Root, TimeSpan.FromMinutes(1)); + Assert.AreNotEqual(0, result.ExitCode, result.Output + result.Error); + StringAssert.Contains(result.Error, "23"); + Assert.IsFalse(File.Exists(marker), "Invalid symbol packages reached NuGet push."); + } + finally + { + Directory.Delete(output, true); + } + } + + private static string Escape(string value) => value.Replace("'", "''", StringComparison.Ordinal); + } +} diff --git a/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs index 3dad244..585bdb4 100644 --- a/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs +++ b/tests/Mammoth.LiteMapper.Packaging.Tests/WorkflowNativeFailureTests.cs @@ -19,6 +19,8 @@ public static IEnumerable NativeFailures() foreach (var step in new[] { "Pack", "Publish exact packages" }) for (var position = 0; position <= 3; position++) yield return new object[] { step == "Pack" ? "pack-and-validate" : "publish", step, position, 3, true }; + for (var position = 0; position <= 1; position++) + yield return new object[] { "pack-and-validate", "Validate package symbols", position, 1, true }; foreach (var job in new[] { "build-and-test", "pack-and-validate" }) for (var position = 0; position <= 1; position++) yield return new object[] { job, "Determine version", position, 1, true }; diff --git a/tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj b/tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj new file mode 100644 index 0000000..36a2962 --- /dev/null +++ b/tools/Mammoth.LiteMapper.PackageValidation/Mammoth.LiteMapper.PackageValidation.csproj @@ -0,0 +1,6 @@ + + + Exe + net10.0 + + diff --git a/tools/Mammoth.LiteMapper.PackageValidation/Program.cs b/tools/Mammoth.LiteMapper.PackageValidation/Program.cs new file mode 100644 index 0000000..cb1b730 --- /dev/null +++ b/tools/Mammoth.LiteMapper.PackageValidation/Program.cs @@ -0,0 +1,39 @@ +using System; +using System.Collections.Generic; + +namespace Mammoth.LiteMapper.PackageValidation +{ + internal static class Program + { + private static int Main(string[] args) + { + if (args.Length == 0 || args.Length % 2 != 0) + { + Console.Error.WriteLine("Usage: Mammoth.LiteMapper.PackageValidation [ ...]"); + return 2; + } + + var errors = new List(); + for (var i = 0; i < args.Length; i += 2) + { + foreach (var error in SymbolPackageValidator.Validate(args[i], args[i + 1])) + { + errors.Add(error); + } + } + + if (errors.Count != 0) + { + foreach (var error in errors) + { + Console.Error.WriteLine(error); + } + + return 1; + } + + Console.WriteLine("Validated " + (args.Length / 2) + " symbol package pair(s)."); + return 0; + } + } +} diff --git a/tools/Mammoth.LiteMapper.PackageValidation/SymbolPackageValidator.cs b/tools/Mammoth.LiteMapper.PackageValidation/SymbolPackageValidator.cs new file mode 100644 index 0000000..a2fb2ca --- /dev/null +++ b/tools/Mammoth.LiteMapper.PackageValidation/SymbolPackageValidator.cs @@ -0,0 +1,264 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Security.Cryptography; +using System.Xml; +using System.Xml.Linq; + +namespace Mammoth.LiteMapper.PackageValidation +{ + public static class SymbolPackageValidator + { + private static readonly HashSet AllowedSymbolExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".pdb", + ".nuspec", + ".xml", + ".psmdcp", + ".rels", + ".p7s" + }; + + /// + /// Validates that every PDB supplied by a symbol package matches a DLL or EXE at the same + /// archive-relative path. A primary package may contain additional assemblies without PDBs. + /// + public static IReadOnlyList Validate(string packagePath, string symbolPackagePath) + { + var errors = new List(); + + if (!packagePath.EndsWith(".nupkg", StringComparison.OrdinalIgnoreCase) || + packagePath.EndsWith(".symbols.nupkg", StringComparison.OrdinalIgnoreCase)) + { + errors.Add(packagePath + ": expected a .nupkg package path."); + } + + if (!symbolPackagePath.EndsWith(".snupkg", StringComparison.OrdinalIgnoreCase)) + { + errors.Add(symbolPackagePath + ": expected a .snupkg symbol package path."); + } + + if (errors.Count != 0) + { + return errors; + } + + try + { + using var package = ZipFile.OpenRead(packagePath); + using var symbolPackage = ZipFile.OpenRead(symbolPackagePath); + ValidateArchives(packagePath, package, symbolPackagePath, symbolPackage, errors); + } + catch (Exception exception) when ( + exception is IOException || + exception is InvalidDataException || + exception is BadImageFormatException || + exception is CryptographicException || + exception is XmlException || + exception is ArgumentException || + exception is UnauthorizedAccessException) + { + errors.Add(symbolPackagePath + ": validation failed: " + exception.Message); + } + + return errors; + } + + private static void ValidateArchives( + string packagePath, + ZipArchive package, + string symbolPackagePath, + ZipArchive symbolPackage, + List errors) + { + var packageIdentity = ReadIdentity(packagePath, package, false, errors); + var symbolIdentity = ReadIdentity(symbolPackagePath, symbolPackage, true, errors); + + if (packageIdentity != null && symbolIdentity != null) + { + if (!string.Equals(packageIdentity.Value.Id, symbolIdentity.Value.Id, StringComparison.OrdinalIgnoreCase)) + { + errors.Add(symbolPackagePath + ": package ID '" + symbolIdentity.Value.Id + "' does not match '" + packageIdentity.Value.Id + "'."); + } + + if (!string.Equals(packageIdentity.Value.Version, symbolIdentity.Value.Version, StringComparison.OrdinalIgnoreCase)) + { + errors.Add(symbolPackagePath + ": package version '" + symbolIdentity.Value.Version + "' does not match '" + packageIdentity.Value.Version + "'."); + } + } + + foreach (var entry in symbolPackage.Entries.Where(static entry => !IsDirectory(entry))) + { + var extension = Path.GetExtension(entry.FullName); + if (!AllowedSymbolExtensions.Contains(extension)) + { + errors.Add(symbolPackagePath + ": entry '" + entry.FullName + "' has unsupported extension '" + extension + "'."); + } + } + + var packageEntries = package.Entries + .Where(static entry => !IsDirectory(entry)) + .ToDictionary(static entry => NormalizePath(entry.FullName), StringComparer.OrdinalIgnoreCase); + var pdbEntries = symbolPackage.Entries + .Where(static entry => !IsDirectory(entry) && entry.FullName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + + if (pdbEntries.Length == 0) + { + errors.Add(symbolPackagePath + ": symbol package does not contain a PDB."); + return; + } + + foreach (var pdbEntry in pdbEntries) + { + var pdbPath = NormalizePath(pdbEntry.FullName); + var stem = pdbPath.Substring(0, pdbPath.Length - 4); + if (!packageEntries.TryGetValue(stem + ".dll", out var peEntry) && + !packageEntries.TryGetValue(stem + ".exe", out peEntry)) + { + errors.Add(symbolPackagePath + ": PDB '" + pdbEntry.FullName + "' has no DLL or EXE at the same archive-relative path in " + packagePath + "."); + continue; + } + + ValidatePdb(symbolPackagePath, pdbEntry, peEntry, errors); + } + } + + private static (string Id, string Version)? ReadIdentity( + string archivePath, + ZipArchive archive, + bool requireSymbolsPackage, + List errors) + { + var nuspecs = archive.Entries + .Where(static entry => !IsDirectory(entry) && entry.FullName.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase)) + .ToArray(); + if (nuspecs.Length != 1) + { + errors.Add(archivePath + ": expected exactly one nuspec, found " + nuspecs.Length + "."); + return null; + } + + using var stream = nuspecs[0].Open(); + var document = XDocument.Load(stream, LoadOptions.None); + var metadata = document.Descendants().FirstOrDefault(static element => element.Name.LocalName == "metadata"); + var id = metadata?.Elements().FirstOrDefault(static element => element.Name.LocalName == "id")?.Value.Trim(); + var version = metadata?.Elements().FirstOrDefault(static element => element.Name.LocalName == "version")?.Value.Trim(); + if (string.IsNullOrEmpty(id) || string.IsNullOrEmpty(version)) + { + errors.Add(archivePath + ": nuspec must contain non-empty id and version metadata."); + return null; + } + + if (requireSymbolsPackage) + { + var isSymbolsPackage = metadata! + .Descendants() + .Any(static element => + element.Name.LocalName == "packageType" && + string.Equals((string?)element.Attribute("name"), "SymbolsPackage", StringComparison.OrdinalIgnoreCase)); + if (!isSymbolsPackage) + { + errors.Add(archivePath + ": nuspec must declare packageType name=" + '"' + "SymbolsPackage" + '"' + "."); + } + } + + return (id, version); + } + + private static void ValidatePdb( + string symbolPackagePath, + ZipArchiveEntry pdbEntry, + ZipArchiveEntry peEntry, + List errors) + { + var pdbBytes = ReadEntry(pdbEntry); + if (pdbBytes.Length < 4 || + pdbBytes[0] != (byte)'B' || + pdbBytes[1] != (byte)'S' || + pdbBytes[2] != (byte)'J' || + pdbBytes[3] != (byte)'B') + { + errors.Add(symbolPackagePath + ": PDB '" + pdbEntry.FullName + "' is not portable."); + return; + } + + var peBytes = ReadEntry(peEntry); + using var peStream = new MemoryStream(peBytes, false); + using var peReader = new PEReader(peStream); + MetadataReaderProvider? pdbProvider = null; + try + { + var associated = peReader.TryOpenAssociatedPortablePdb( + peEntry.FullName, + _ => new MemoryStream(pdbBytes, false), + out pdbProvider, + out var associatedPdbPath); + if (!associated || pdbProvider == null || associatedPdbPath == null) + { + errors.Add(symbolPackagePath + ": PDB '" + pdbEntry.FullName + "' does not match the PE identity for '" + peEntry.FullName + "'."); + return; + } + + var checksumEntries = peReader.ReadDebugDirectory() + .Where(static entry => entry.Type == DebugDirectoryEntryType.PdbChecksum) + .Select(peReader.ReadPdbChecksumDebugDirectoryData) + .ToArray(); + if (checksumEntries.Length == 0) + { + errors.Add(symbolPackagePath + ": PE '" + peEntry.FullName + "' does not contain a compiler PDB checksum."); + return; + } + + var pdbReader = pdbProvider.GetMetadataReader(); + var idOffset = pdbReader.DebugMetadataHeader!.IdStartOffset; + if (idOffset < 0 || idOffset > pdbBytes.Length - 20) + { + errors.Add(symbolPackagePath + ": PDB '" + pdbEntry.FullName + "' has an invalid portable PDB ID offset."); + return; + } + + foreach (var checksumEntry in checksumEntries) + { + using var hash = IncrementalHash.CreateHash(new HashAlgorithmName(checksumEntry.AlgorithmName)); + hash.AppendData(pdbBytes, 0, idOffset); + hash.AppendData(new byte[20]); + hash.AppendData(pdbBytes, idOffset + 20, pdbBytes.Length - idOffset - 20); + if (checksumEntry.Checksum.SequenceEqual(hash.GetHashAndReset())) + { + return; + } + } + + errors.Add(symbolPackagePath + ": PDB '" + pdbEntry.FullName + "' does not match the compiler checksum in '" + peEntry.FullName + "'."); + } + finally + { + pdbProvider?.Dispose(); + } + } + + private static byte[] ReadEntry(ZipArchiveEntry entry) + { + using var stream = entry.Open(); + using var memory = new MemoryStream(); + stream.CopyTo(memory); + return memory.ToArray(); + } + + private static bool IsDirectory(ZipArchiveEntry entry) + { + return entry.FullName.EndsWith("/", StringComparison.Ordinal) || + entry.FullName.EndsWith("\\", StringComparison.Ordinal); + } + + private static string NormalizePath(string path) + { + return path.Replace('\\', '/'); + } + } +}