From b094836be14d8fe274fd89be413c0f788336b4f2 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:39:49 -0400 Subject: [PATCH 1/2] Add tests for the SSMS installer's VSIX signature check CheckVsix decides whether a signed installer accepts a VSIX, and the only end-to-end check so far is the --verify-only run in release.yml, which sees one real signature that passes. This adds a net472 test project that builds each VSIX at test time with System.IO.Packaging, signs it with certificates made in memory, and asserts the reason CheckVsix returns. - tests/PlanViewer.Ssms.Installer.Tests: 77 tests in five classes (signature, entries, case-only names, relationship parts, origin and certificate parts). - InternalsVisibleTo on the installer project, so the tests can call CheckVsix. - .github/workflows/ssms-installer-tests.yml runs the project on windows-latest. The project is not in PlanViewer.sln: ci.yml builds that on ubuntu-latest. No installer logic changed. Closes #618 Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019tS6P95Dtzs4aeMpb1xqzX --- .github/workflows/ssms-installer-tests.yml | 36 +++ .../PlanViewer.Ssms.Installer.csproj | 5 + .../PlanViewer.Ssms.Installer.Tests.csproj | 42 +++ .../TestCertificates.cs | 41 +++ .../VsixCaseTests.cs | 176 +++++++++++ .../VsixEntryTests.cs | 127 ++++++++ .../VsixFixture.cs | 291 ++++++++++++++++++ .../VsixOriginAndCertificateTests.cs | 218 +++++++++++++ .../VsixRelationshipTests.cs | 272 ++++++++++++++++ .../VsixSignatureTests.cs | 222 +++++++++++++ .../VsixTestBase.cs | 69 +++++ 11 files changed, 1499 insertions(+) create mode 100644 .github/workflows/ssms-installer-tests.yml create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/PlanViewer.Ssms.Installer.Tests.csproj create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/TestCertificates.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixCaseTests.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixEntryTests.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixFixture.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixOriginAndCertificateTests.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixRelationshipTests.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixSignatureTests.cs create mode 100644 tests/PlanViewer.Ssms.Installer.Tests/VsixTestBase.cs diff --git a/.github/workflows/ssms-installer-tests.yml b/.github/workflows/ssms-installer-tests.yml new file mode 100644 index 0000000..d086001 --- /dev/null +++ b/.github/workflows/ssms-installer-tests.yml @@ -0,0 +1,36 @@ +name: SSMS installer tests + +# Tests for the signature check of InstallSsmsExtension.exe (src/PlanViewer.Ssms.Installer). +# The check uses System.IO.Packaging.PackageDigitalSignatureManager, which exists only on +# .NET Framework. The installer targets net472, so its tests do too, and they need Windows. +# That is why they are not in PlanViewer.sln: ci.yml builds the solution on ubuntu-latest and +# cannot run net472 tests. This workflow builds and runs only the new test project. +# It is not a required check, so the path filter below is safe: a PR that skips it is not blocked. + +on: + pull_request: + branches: [dev, main] + paths: + - 'src/PlanViewer.Ssms.Installer/**' + - 'tests/PlanViewer.Ssms.Installer.Tests/**' + - 'Directory.Packages.props' + - '.github/workflows/ssms-installer-tests.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + test: + runs-on: windows-latest + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup .NET 10.0 + uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0 + with: + dotnet-version: 10.0.x + + - name: Run the SSMS installer tests + run: dotnet test tests/PlanViewer.Ssms.Installer.Tests/PlanViewer.Ssms.Installer.Tests.csproj -c Release --verbosity normal -- --hangdump --hangdump-timeout 5m --hangdump-type none diff --git a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj index c14f793..309186b 100644 --- a/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj +++ b/src/PlanViewer.Ssms.Installer/PlanViewer.Ssms.Installer.csproj @@ -13,4 +13,9 @@ + + + + + diff --git a/tests/PlanViewer.Ssms.Installer.Tests/PlanViewer.Ssms.Installer.Tests.csproj b/tests/PlanViewer.Ssms.Installer.Tests/PlanViewer.Ssms.Installer.Tests.csproj new file mode 100644 index 0000000..6d46595 --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/PlanViewer.Ssms.Installer.Tests.csproj @@ -0,0 +1,42 @@ + + + + + net472 + Exe + latest + enable + + false + true + true + + + --timeout 15m + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/PlanViewer.Ssms.Installer.Tests/TestCertificates.cs b/tests/PlanViewer.Ssms.Installer.Tests/TestCertificates.cs new file mode 100644 index 0000000..10c9a55 --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/TestCertificates.cs @@ -0,0 +1,41 @@ +using System; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// Self-signed certificates that the tests make for themselves. Each one lives in memory only: no +/// certificate or key is committed, written to a file or added to a Windows certificate store. +/// +internal static class TestCertificates +{ + // CertificateRequest.CreateSelfSigned makes a certificate with an ephemeral CNG key. The + // signing API of System.IO.Packaging signs with it as it is, on .NET Framework 4.8 as well as + // on the CI runner, so there is no PFX export and import round trip and no key on disk. + public static X509Certificate2 Create(string subject) + { + using (var rsa = RSA.Create(2048)) + { + var request = new CertificateRequest("CN=" + subject, rsa, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + request.CertificateExtensions.Add(new X509KeyUsageExtension(X509KeyUsageFlags.DigitalSignature, true)); + var now = DateTimeOffset.UtcNow; + return request.CreateSelfSigned(now.AddDays(-1), now.AddYears(1)); + } + } + + /// The certificate that the tests treat as the certificate of the signed installer. + public static X509Certificate2 Installer { get; } = Create("Test installer"); + + /// A second certificate, for a VSIX that another signer signed. + public static X509Certificate2 Other { get; } = Create("Other signer"); + + /// + /// The certificate as the installer holds it. X509Certificate.CreateFromSignedFile returns the + /// public certificate as a plain X509Certificate, without a private key, and so does this. + /// + public static X509Certificate AsInstallerCertificate(X509Certificate2 certificate) + { + return new X509Certificate(certificate.Export(X509ContentType.Cert)); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixCaseTests.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixCaseTests.cs new file mode 100644 index 0000000..4877e72 --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixCaseTests.cs @@ -0,0 +1,176 @@ +using System.IO.Packaging; +using static PlanViewer.Ssms.Installer.Tests.VsixFixture; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// ZIP entry names that differ only in case. The check compares names exactly, so such names are +/// different names. Windows treats them as one file when it unpacks the VSIX, so a name in another +/// case must never count as covered. The check also refuses a second entry with the same name in any +/// case. OPC refuses some of these files by itself, before the coverage rule runs. Then the reason +/// says that the installer failed to read the file. +/// +public class VsixCaseTests : VsixTestBase +{ + const string EmptyContentTypes = ""; + + [Fact] + public void TwoEntriesThatDifferOnlyInCaseAreBothRefusedAndTheSecondIsNamedAsARepeat() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => + { + AddEntry(zip, "data.xyz", Bytes("The first.")); + AddEntry(zip, "DATA.XYZ", Bytes("The second.")); + }); + + // Neither entry is covered. The second one is also a second entry with the name of the first. + Assert.Equal( + DoesNotCover("/DATA.XYZ (a second entry with the same name), /data.xyz"), + Check(vsix)); + } + + [Fact] + public void ASecondContentTypesEntryInAnotherCaseIsRefusedAsASecondEntryWithTheSameName() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "[CONTENT_TYPES].XML", Bytes(EmptyContentTypes))); + + // The first [Content_Types].xml is one of the entries that need no signature. The second is not. + Assert.Equal(DoesNotCover("/[CONTENT_TYPES].XML (a second entry with the same name)"), Check(vsix)); + } + + [Fact] + public void ASecondEntryWithTheNameOfASignedPartInAnotherCaseIsRefusedByOpcBeforeTheCoverageRule() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "planviewer.ssms.DLL", Bytes("MZ another assembly."))); + + Assert.StartsWith(FailedToRead, Check(vsix)); + } + + [Fact] + public void ASecondEntryWithTheSameNameAsASignedPartIsRefusedByOpcBeforeTheCoverageRule() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, EntryName(Assembly), Bytes("MZ another assembly."))); + + Assert.StartsWith(FailedToRead, Check(vsix)); + } + + [Fact] + public void ASignedPartRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => RenameEntry(zip, EntryName(Assembly), "planviewer.ssms.dll")); + + // OPC compares part names without regard to case, so the signature still verifies. + // Only the exact comparison of the check sees that the entry is not the signed one. + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Equal(DoesNotCover("/planviewer.ssms.dll"), Check(vsix)); + } + + [Fact] + public void TheContentTypesEntryRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => RenameEntry(zip, ContentTypesEntry, "[content_types].xml")); + + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Equal(DoesNotCover("/[content_types].xml"), Check(vsix)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void TheRelationshipPartOfASignedPartRenamedToAnotherCaseIsRefused(bool signedWhole) + { + var vsix = NewVsix(); + CreateUnsigned(vsix, package => package.GetPart(PartUri(Manifest)).CreateRelationship(PartUri(License), TargetMode.Internal, TestRelationship, "rIdLicense")); + if (signedWhole) + Sign(vsix, TestCertificates.Installer, parts: ContentAndRelationshipParts(Manifest)); + else + Sign(vsix, TestCertificates.Installer, selectors: new[] { new PackageRelationshipSelector(PartUri(Manifest), PackageRelationshipSelectorType.Id, "rIdLicense") }); + + // Before the rename the file passes, so the rename is the only reason to refuse it. + Assert.Null(Check(vsix)); + EditZip(vsix, zip => RenameEntry(zip, "_rels/extension.vsixmanifest.rels", "_rels/Extension.vsixmanifest.rels")); + + Assert.Equal(DoesNotCover("/_rels/Extension.vsixmanifest.rels"), Check(vsix)); + } + + [Fact] + public void ThePackageRelationshipPartRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => RenameEntry(zip, "_rels/.rels", "_RELS/.rels")); + + Assert.Equal(DoesNotCover("/_RELS/.rels"), Check(vsix)); + } + + [Fact] + public void TheOriginPartRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => RenameEntry(zip, EntryName(OriginPart), "package/services/digital-signature/ORIGIN.psdsor")); + + // The reason lists the entry and then a relationship, whose id OPC picks at random. + var reason = Check(vsix); + Assert.NotNull(reason); + Assert.StartsWith(DoesNotCover("/package/services/digital-signature/ORIGIN.psdsor"), reason); + } + + [Fact] + public void TheRelationshipPartOfTheOriginPartRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => RenameEntry(zip, "package/services/digital-signature/_rels/origin.psdsor.rels", "package/services/digital-signature/_rels/ORIGIN.psdsor.rels")); + + Assert.Equal(DoesNotCover("/package/services/digital-signature/_rels/ORIGIN.psdsor.rels"), Check(vsix)); + } + + [Fact] + public void TheSignaturePartRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => + { + var name = EntryWithExtension(zip, ".psdsxs"); + RenameEntry(zip, name, name.Replace("xml-signature/", "XML-SIGNATURE/")); + }); + + // The relationship of the origin part now points to an entry that has another name. + var reason = Check(vsix); + Assert.NotNull(reason); + Assert.StartsWith(DoesNotCover("relationship R"), reason); + Assert.EndsWith(" of " + OriginPart, reason); + } + + [Fact] + public void TheCertificatePartRenamedToAnotherCaseIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.InCertificatePart); + EditZip(vsix, zip => + { + var name = EntryWithExtension(zip, ".cer"); + RenameEntry(zip, name, name.Replace("certificate/", "CERTIFICATE/")); + }); + + // The name of the certificate part comes from the thumbprint, which changes with every run. + var reason = Check(vsix); + Assert.NotNull(reason); + Assert.StartsWith(DoesNotCover("/package/services/digital-signature/CERTIFICATE/"), reason); + Assert.Contains(".cer, relationship R", reason); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixEntryTests.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixEntryTests.cs new file mode 100644 index 0000000..1bf70b2 --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixEntryTests.cs @@ -0,0 +1,127 @@ +using System.IO.Packaging; +using System.Linq; +using static PlanViewer.Ssms.Installer.Tests.VsixFixture; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// The last step of Program.CheckVsix: every entry of the ZIP file must be covered by the signature +/// or be one of the few entries that a signature cannot cover. The list of entries comes from the raw +/// ZIP file, not from the parts that OPC finds, so an entry that OPC does not list as a part is caught +/// too. In each test the signature still verifies, so it is the entry rule that refuses the file. +/// +public class VsixEntryTests : VsixTestBase +{ + [Fact] + public void AnExtraPartThatTheSignatureDoesNotCoverIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "extra.txt", Bytes("Added after signing."))); + + // The extension txt has a content type, so OPC lists the entry as a part. + Assert.Contains("/extra.txt", PartNames(vsix)); + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + + Assert.Equal(DoesNotCover("/extra.txt"), Check(vsix)); + } + + [Fact] + public void AnExtraEntryThatOpcDoesNotListAsAPartIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "payload.exe", Bytes("MZ added after signing."))); + + // The extension exe has no content type, so OPC does not see the entry. The check does. + Assert.DoesNotContain("/payload.exe", PartNames(vsix)); + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + + Assert.Equal(DoesNotCover("/payload.exe"), Check(vsix)); + } + + [Fact] + public void APartThatWasLeftOutOfTheSignatureIsRefused() + { + var vsix = NewVsix(); + CreateUnsigned(vsix, package => AddPart(package, "/unsigned.txt", "text/plain", Bytes("The signer did not sign this part."))); + Sign(vsix, TestCertificates.Installer, parts: package => ContentParts(package).Where(u => u.OriginalString != "/unsigned.txt").ToList()); + + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Equal(DoesNotCover("/unsigned.txt"), Check(vsix)); + } + + [Fact] + public void ASignatureThatSignsOnlySomePartsLeavesTheOthersUncovered() + { + var vsix = NewVsix(); + CreateUnsigned(vsix); + Sign(vsix, TestCertificates.Installer, parts: package => new[] { PartUri(Manifest) }); + + // Five parts are uncovered. The reason names the first three, in ordinal order, and counts the rest. + Assert.Equal( + DoesNotCover("/Dir/My%20File.TXT, /LICENSE.txt, /PlanViewer.Ssms.dll and 2 more"), + Check(vsix)); + } + + [Fact] + public void AnEntryThatIsNamedLikeAPartOfTheSignatureButIsNotThePartOfTheSignatureIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "package/services/digital-signature/xml-signature/other.psdsxs", Bytes(""))); + + Assert.Equal(DoesNotCover("/package/services/digital-signature/xml-signature/other.psdsxs"), Check(vsix)); + } + + [Theory] + [InlineData("Dir/")] + [InlineData("../evil.bin")] + [InlineData("Dir\\evil.bin")] + public void AnEntryWithADirectoryNameOrAPathThatLeavesTheFolderIsRefused(string entryName) + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, entryName, Bytes(""))); + + Assert.Equal(DoesNotCover("/" + entryName), Check(vsix)); + } + + [Fact] + public void AControlCharacterInAnEntryNameIsShownAsAQuestionMark() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + + // U+009B is the 8-bit form of the escape sequence introducer. ZipArchive and OPC accept it in a name. + EditZip(vsix, zip => AddEntry(zip, "evil\u009b[31m.bin", Bytes("x"))); + + // The reason is printed to the console, so it must not carry the control character itself. + Assert.Equal(DoesNotCover("/evil?[31m.bin"), Check(vsix)); + } + + [Fact] + public void AnEscapeCharacterInAnEntryNameMakesTheFileUnreadableAndIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "evil_[31m.bin", Bytes("x"))); + + // ZipArchive will not write an escape character in a name, so the test puts it in the bytes of the + // file. A file that someone builds by hand can hold one, and the ZIP reader of .NET Framework refuses it. + PatchEntryName(vsix, "evil_[31m.bin", "evil\u001b[31m.bin"); + + Assert.StartsWith(FailedToRead, Check(vsix)); + } + + [Fact] + public void ALongEntryNameIsCutShortInTheReason() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + var name = new string('a', 100) + ".bin"; + EditZip(vsix, zip => AddEntry(zip, name, Bytes("x"))); + + Assert.Equal(DoesNotCover("/" + new string('a', 80) + "..."), Check(vsix)); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixFixture.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixFixture.cs new file mode 100644 index 0000000..b526766 --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixFixture.cs @@ -0,0 +1,291 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.IO.Packaging; +using System.Linq; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// Builds small VSIX packages at test time and edits them the way a signer, or someone who tampers +/// with a file, could. It builds the package with System.IO.Packaging and signs it with the signing +/// API of that library, PackageDigitalSignatureManager. It edits the ZIP entries directly where a +/// test needs something that OPC would not write. Nothing here is a checked-in binary. +/// +internal static class VsixFixture +{ + public const string ContentTypesEntry = "[Content_Types].xml"; + + public const string SignatureRelationshipPrefix = "http://schemas.openxmlformats.org/package/2006/relationships/digital-signature/"; + public const string OriginRelationship = SignatureRelationshipPrefix + "origin"; + public const string CertificateRelationship = SignatureRelationshipPrefix + "certificate"; + public const string OriginContentType = "application/vnd.openxmlformats-package.digital-signature-origin"; + public const string CertificateContentType = "application/vnd.openxmlformats-package.digital-signature-certificate"; + + /// A relationship type of the tests. It has no meaning to OPC. + public const string TestRelationship = "http://example.com/relationships/related"; + + /// The folder that System.IO.Packaging puts the parts of a signature in. + public const string SignatureFolder = "/package/services/digital-signature/"; + + /// The origin part that System.IO.Packaging creates, when a test does not make its own. + public const string OriginPart = SignatureFolder + "origin.psdsor"; + + // The parts of the extension that the tests build. The mixed case and the escape in the last + // name are deliberate: entry names are compared exactly, as they are written. + public const string Manifest = "/extension.vsixmanifest"; + public const string Assembly = "/PlanViewer.Ssms.dll"; + public const string License = "/LICENSE.txt"; + public const string MixedCase = "/Dir/My%20File.TXT"; + + static readonly (string Name, string ContentType, string Text)[] DefaultParts = + { + (Manifest, "text/xml", ""), + ("/catalog.json", "application/json", "{ \"manifestVersion\": \"1.1\" }"), + (Assembly, "application/octet-stream", "MZ this stands in for the assembly"), + ("/PlanViewer.Ssms.pkgdef", "text/plain", "[$RootKey$\\Packages]"), + (License, "text/plain", "The license text."), + (MixedCase, "text/plain", "A part with mixed case and an escape in its name."), + }; + + /// The names of the parts that makes. + public static List DefaultPartNames => DefaultParts.Select(p => p.Name).ToList(); + + public static byte[] Bytes(string text) => Encoding.UTF8.GetBytes(text); + + public static Uri PartUri(string name) => new Uri(name, UriKind.Relative); + + /// Makes an unsigned package that holds the parts of a small extension. Nothing is signed. + public static void CreateUnsigned(string path, Action? addMore = null) + { + using (var package = Package.Open(path, FileMode.Create, FileAccess.ReadWrite)) + { + foreach (var part in DefaultParts) + AddPart(package, part.Name, part.ContentType, Bytes(part.Text)); + addMore?.Invoke(package); + } + } + + public static PackagePart AddPart(Package package, string name, string contentType, byte[] bytes) + { + var part = package.CreatePart(PartUri(name), contentType); + using (var stream = part.GetStream(FileMode.Create, FileAccess.Write)) + stream.Write(bytes, 0, bytes.Length); + return part; + } + + /// The parts that a signer signs when it signs the content: no relationship parts and no signature parts. + public static List ContentParts(Package package) + { + return package.GetParts() + .Select(p => p.Uri) + .Where(u => !PackUriHelper.IsRelationshipPartUri(u)) + .Where(u => !u.OriginalString.StartsWith(SignatureFolder, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Signs the package in place with the signing API of System.IO.Packaging. Without a list of parts it + /// signs every content part. It signs the relationship parts and single relationships only when the + /// test passes them. + /// + public static void Sign( + string path, + X509Certificate2 certificate, + CertificateEmbeddingOption embedding = CertificateEmbeddingOption.InSignaturePart, + Func>? parts = null, + IEnumerable? selectors = null) + { + using (var package = Package.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + { + var manager = new PackageDigitalSignatureManager(package) { CertificateOption = embedding }; + var signedParts = (parts ?? ContentParts)(package).ToList(); + if (selectors == null) + manager.Sign(signedParts, certificate); + else + manager.Sign(signedParts, certificate, selectors.ToList()); + } + } + + /// Signs every content part and, on top of those, the relationship part of each source that the test names. + public static Func> ContentAndRelationshipParts(params string[] sources) + { + return package => ContentParts(package) + .Concat(sources.Select(s => PackUriHelper.GetRelationshipPartUri(PartUri(s)))) + .ToList(); + } + + /// Builds a package with the default parts and signs all of them with the certificate. + public static void CreateSigned(string path, X509Certificate2 certificate, CertificateEmbeddingOption embedding = CertificateEmbeddingOption.InSignaturePart) + { + CreateUnsigned(path); + Sign(path, certificate, embedding); + } + + /// Opens a package that may be signed, for changes through System.IO.Packaging. + public static void EditPackage(string path, Action edit) + { + using (var package = Package.Open(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + edit(package); + } + + /// The signature part of the first signature of the package. + public static PackagePart SignaturePart(Package package) => new PackageDigitalSignatureManager(package).Signatures[0].SignaturePart; + + /// Adds a relationship to a part that exists, or to the package when the source is "/". + public static void AddRelationship(string path, string source, string id, string type, string target, TargetMode mode = TargetMode.Internal) + { + EditPackage(path, package => + { + var targetUri = new Uri(target, mode == TargetMode.Internal ? UriKind.Relative : UriKind.Absolute); + if (source == "/") + package.CreateRelationship(targetUri, mode, type, id); + else + package.GetPart(PartUri(source)).CreateRelationship(targetUri, mode, type, id); + }); + } + + /// + /// Opens the ZIP file for changes to its entries. Close the package before this: the file has to + /// be free, and the package writes its parts when it closes. + /// + public static void EditZip(string path, Action edit) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None)) + using (var zip = new ZipArchive(stream, ZipArchiveMode.Update)) + edit(zip); + } + + public static List EntryNames(string path) + { + using (var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var zip = new ZipArchive(stream, ZipArchiveMode.Read)) + return zip.Entries.Select(e => e.FullName).ToList(); + } + + /// The one ZIP entry that has this extension. It fails when there is none or more than one. + public static string EntryWithExtension(string path, string extension) + { + return EntryNames(path).Single(n => n.EndsWith(extension, StringComparison.OrdinalIgnoreCase)); + } + + /// The same, inside a ZIP file that a test has open for changes. + public static string EntryWithExtension(ZipArchive zip, string extension) + { + return zip.Entries.Select(e => e.FullName).Single(n => n.EndsWith(extension, StringComparison.OrdinalIgnoreCase)); + } + + /// The names of the parts that OPC lists in the package. An entry without a content type is not among them. + public static List PartNames(string path) + { + using (var package = Package.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + return package.GetParts().Select(p => p.Uri.OriginalString).ToList(); + } + + /// The ZIP entries of the parts that the signatures of the file sign. + public static List SignedEntryNames(string path) + { + using (var package = Package.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + return new PackageDigitalSignatureManager(package).Signatures + .SelectMany(s => s.SignedParts) + .Select(EntryName) + .ToList(); + } + } + + public static byte[] ReadEntry(ZipArchive zip, string name) + { + using (var stream = zip.GetEntry(name)!.Open()) + using (var copy = new MemoryStream()) + { + stream.CopyTo(copy); + return copy.ToArray(); + } + } + + /// Adds an entry. It does not check for an entry with the same name, so a test can add a second one. + public static void AddEntry(ZipArchive zip, string name, byte[] bytes) + { + var entry = zip.CreateEntry(name); + if (bytes.Length == 0) + return; + using (var stream = entry.Open()) + stream.Write(bytes, 0, bytes.Length); + } + + /// Replaces the bytes of an entry by deleting it and adding it again under the same name. + public static void ReplaceEntry(ZipArchive zip, string name, byte[] bytes) + { + zip.GetEntry(name)!.Delete(); + AddEntry(zip, name, bytes); + } + + public static void RenameEntry(ZipArchive zip, string name, string newName) + { + var bytes = ReadEntry(zip, name); + zip.GetEntry(name)!.Delete(); + AddEntry(zip, newName, bytes); + } + + /// + /// Changes an entry name in the bytes of the ZIP file, in the local header and in the central directory, + /// without any check. ZipArchive refuses to write some names, such as a name with a control character, + /// and a file that someone builds by hand can still hold one. Both names must have the same length. + /// + public static void PatchEntryName(string path, string name, string patchedName) + { + var oldBytes = Encoding.UTF8.GetBytes(name); + var newBytes = Encoding.UTF8.GetBytes(patchedName); + if (oldBytes.Length != newBytes.Length) + throw new ArgumentException("The patched name must have the same length in bytes."); + + var file = File.ReadAllBytes(path); + var found = 0; + for (var i = 0; i <= file.Length - oldBytes.Length; i++) + { + var match = true; + for (var j = 0; j < oldBytes.Length && match; j++) + match = file[i + j] == oldBytes[j]; + if (!match) + continue; + Array.Copy(newBytes, 0, file, i, newBytes.Length); + found++; + i += oldBytes.Length - 1; + } + + if (found != 2) + throw new InvalidOperationException($"Expected the name {name} twice in the ZIP file (local header and central directory), found {found}."); + File.WriteAllBytes(path, file); + } + + /// The name of the ZIP entry that holds the part: its name without the leading slash. + public static string EntryName(Uri partUri) => EntryName(partUri.OriginalString); + + public static string EntryName(string partName) => partName.Substring(1); + + /// Changes the first character of the signature value in the signature part, so that the signature no longer matches. + public static void FlipSignatureValue(ZipArchive zip) + { + var name = EntryWithExtension(zip, ".psdsxs"); + var text = Encoding.UTF8.GetString(ReadEntry(zip, name)); + var match = System.Text.RegularExpressions.Regex.Match(text, "]*>([A-Za-z0-9+/])"); + if (!match.Success) + throw new InvalidOperationException("The signature part has no signature value."); + var first = match.Groups[1]; + var changed = first.Value == "A" ? "B" : "A"; + ReplaceEntry(zip, name, Bytes(text.Substring(0, first.Index) + changed + text.Substring(first.Index + 1))); + } + + /// The text of an entry, changed by a text replacement, written back under the same name. + public static void ReplaceInEntry(ZipArchive zip, string name, string oldText, string newText) + { + var text = Encoding.UTF8.GetString(ReadEntry(zip, name)); + if (!text.Contains(oldText)) + throw new InvalidOperationException($"The entry {name} has no text {oldText}."); + ReplaceEntry(zip, name, Bytes(text.Replace(oldText, newText))); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixOriginAndCertificateTests.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixOriginAndCertificateTests.cs new file mode 100644 index 0000000..793accc --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixOriginAndCertificateTests.cs @@ -0,0 +1,218 @@ +using System.IO.Packaging; +using System.Linq; +using static PlanViewer.Ssms.Installer.Tests.VsixFixture; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// The parts that belong to the signature: the origin part, the signature part and the certificate +/// parts. The signature signs none of them, except when a signer chooses to sign the origin part. +/// The check accepts the origin part when it is signed or empty, and accepts a certificate part only +/// when it holds exactly the certificate of the installer. Relationships of the origin part and the +/// signature part may point only to entries that the check has accepted. +/// +public class VsixOriginAndCertificateTests : VsixTestBase +{ + const string ExtraCertificate = SignatureFolder + "certificate/extra.cer"; + const string ExtraCertificateRelationshipId = "rIdCertificate2"; + + // ---- the origin part ---- + + // The signing API makes the origin part itself, and leaves it empty and unsigned. These tests make + // the origin part first, with the bytes and the signature that the test wants. + string SignedWithOriginPart(byte[] originBytes, bool signOriginPart) + { + var vsix = NewVsix(); + CreateUnsigned(vsix, package => + { + AddPart(package, OriginPart, OriginContentType, originBytes); + package.CreateRelationship(PartUri(OriginPart), TargetMode.Internal, OriginRelationship, "rIdOrigin"); + }); + + if (signOriginPart) + Sign(vsix, TestCertificates.Installer, parts: package => ContentParts(package).Concat(new[] { PartUri(OriginPart) }).ToList()); + else + Sign(vsix, TestCertificates.Installer); + + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Equal(signOriginPart, SignedEntryNames(vsix).Contains(EntryName(OriginPart))); + return vsix; + } + + [Fact] + public void AnEmptyOriginPartThatIsNotSignedIsAccepted() + { + var vsix = SignedWithOriginPart(new byte[0], signOriginPart: false); + + Assert.Null(Check(vsix)); + } + + [Fact] + public void AnOriginPartWithContentThatTheSignatureSignsIsAccepted() + { + var vsix = SignedWithOriginPart(Bytes("The signer put content in the origin part."), signOriginPart: true); + + Assert.Null(Check(vsix)); + } + + [Fact] + public void AnOriginPartWithContentThatIsNotSignedIsRefused() + { + var vsix = SignedWithOriginPart(Bytes("Content that no signature covers."), signOriginPart: false); + + Assert.Equal(DoesNotCover(OriginPart), Check(vsix)); + } + + // ---- relationships of the origin part and the signature part ---- + + [Fact] + public void ARelationshipOfTheOriginPartThatPointsToASignedPartIsAccepted() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, OriginPart, "rIdExtra", TestRelationship, License); + + Assert.Null(Check(vsix)); + } + + [Fact] + public void ARelationshipOfTheSignaturePartThatPointsToASignedPartIsAccepted() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditPackage(vsix, package => SignaturePart(package).CreateRelationship(PartUri(License), TargetMode.Internal, TestRelationship, "rIdExtra")); + + Assert.Null(Check(vsix)); + } + + [Fact] + public void ARelationshipOfTheOriginPartThatPointsToAnEntryNoSignatureCoversIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => AddEntry(zip, "extra.txt", Bytes("Added after signing."))); + AddRelationship(vsix, OriginPart, "rIdExtra", TestRelationship, "/extra.txt"); + + // The entry is refused for having no cover, and so is the relationship that points to it. + Assert.Equal(DoesNotCover("/extra.txt, relationship rIdExtra of " + OriginPart), Check(vsix)); + } + + [Fact] + public void ARelationshipOfTheOriginPartThatPointsToAMissingEntryIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, OriginPart, "rIdExtra", TestRelationship, "/missing.txt"); + + Assert.Equal(DoesNotCover("relationship rIdExtra of " + OriginPart), Check(vsix)); + } + + [Fact] + public void ARelationshipOfTheOriginPartThatPointsOutsideThePackageIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, OriginPart, "rIdExtra", TestRelationship, "http://example.com/payload", TargetMode.External); + + Assert.Equal(DoesNotCover("relationship rIdExtra of " + OriginPart), Check(vsix)); + } + + [Fact] + public void ARelationshipOfTheSignaturePartThatPointsOutsideThePackageIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditPackage(vsix, package => SignaturePart(package).CreateRelationship(new System.Uri("http://example.com/payload"), TargetMode.External, TestRelationship, "rIdExtra")); + + // The name of the signature part holds a GUID that changes with every run. + var reason = Check(vsix); + Assert.NotNull(reason); + Assert.StartsWith(DoesNotCover("relationship rIdExtra of " + SignatureFolder + "xml-signature/"), reason); + } + + // ---- certificate parts ---- + + void AddCertificatePart(string vsix, byte[] bytes, string contentType = CertificateContentType, bool linkedFromSignaturePart = true) + { + EditPackage(vsix, package => + { + var signaturePart = SignaturePart(package); + var certificatePart = AddPart(package, ExtraCertificate, contentType, bytes); + if (linkedFromSignaturePart) + signaturePart.CreateRelationship(certificatePart.Uri, TargetMode.Internal, CertificateRelationship, ExtraCertificateRelationshipId); + }); + } + + // The reason for a certificate part that the check does not accept. It lists the part and then the + // relationship of the signature part that links to it. The name of the signature part holds a GUID. + static void AssertPartAndLinkRefused(string? reason) + { + Assert.NotNull(reason); + Assert.StartsWith( + DoesNotCover(ExtraCertificate + ", relationship " + ExtraCertificateRelationshipId + " of " + SignatureFolder + "xml-signature/"), + reason); + } + + [Fact] + public void ACertificatePartWithExactlyTheCertificateOfTheInstallerIsAccepted() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddCertificatePart(vsix, TestCertificates.Installer.RawData); + + Assert.Contains(EntryName(ExtraCertificate), EntryNames(vsix)); + Assert.Null(Check(vsix)); + } + + [Fact] + public void ACertificatePartNextToTheCertificatePartOfTheSignatureIsAcceptedWhenItHoldsTheSameCertificate() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.InCertificatePart); + AddCertificatePart(vsix, TestCertificates.Installer.RawData); + + Assert.Equal(2, EntryNames(vsix).Count(n => n.EndsWith(".cer", System.StringComparison.OrdinalIgnoreCase))); + Assert.Null(Check(vsix)); + } + + [Fact] + public void AnExtraCertificatePartWithAnotherCertificateIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.InCertificatePart); + AddCertificatePart(vsix, TestCertificates.Other.RawData); + + AssertPartAndLinkRefused(Check(vsix)); + } + + [Fact] + public void ACertificatePartWithOneByteAddedToTheCertificateIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.InCertificatePart); + AddCertificatePart(vsix, TestCertificates.Installer.RawData.Concat(new byte[] { 0 }).ToArray()); + + AssertPartAndLinkRefused(Check(vsix)); + } + + [Fact] + public void ACertificatePartThatTheSignaturePartDoesNotLinkToIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddCertificatePart(vsix, TestCertificates.Installer.RawData, linkedFromSignaturePart: false); + + // The bytes are the certificate of the installer, but no certificate relationship leads to the part. + Assert.Equal(DoesNotCover(ExtraCertificate), Check(vsix)); + } + + [Fact] + public void ACertificatePartWithAnotherContentTypeIsRefusedByOpcBeforeTheCoverageRule() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddCertificatePart(vsix, TestCertificates.Installer.RawData, contentType: "application/octet-stream"); + + Assert.StartsWith(FailedToRead, Check(vsix)); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixRelationshipTests.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixRelationshipTests.cs new file mode 100644 index 0000000..33589fb --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixRelationshipTests.cs @@ -0,0 +1,272 @@ +using System.IO; +using System.IO.Packaging; +using System.Linq; +using static PlanViewer.Ssms.Installer.Tests.VsixFixture; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// Relationship parts, the .rels entries. A relationship part is covered when the signature signs +/// it whole or selects every relationship in it. The origin relationship of the package is the one +/// exception: a signer adds it after it signs, so it needs no cover. It is exempt only as the relationship +/// of the package itself, of the origin type, that points to the origin part. Every other relationship +/// with no cover is refused, whatever its type. +/// +/// The parts of a VSIX have no relationships until a signer adds one, so each test that needs a +/// relationship adds it to the fixture first. +/// +public class VsixRelationshipTests : VsixTestBase +{ + const string LicenseRelationshipId = "rIdLicense"; + + // The relationship of the manifest part to the license part, before any signing. + static void AddLicenseRelationship(Package package) + { + package.GetPart(PartUri(Manifest)).CreateRelationship(PartUri(License), TargetMode.Internal, TestRelationship, LicenseRelationshipId); + } + + static PackageRelationshipSelector SelectLicenseRelationshipById() + { + return new PackageRelationshipSelector(PartUri(Manifest), PackageRelationshipSelectorType.Id, LicenseRelationshipId); + } + + string UnsignedWithLicenseRelationship() + { + var vsix = NewVsix(); + CreateUnsigned(vsix, AddLicenseRelationship); + return vsix; + } + + // ---- relationship parts of ordinary parts ---- + + [Fact] + public void ARelationshipPartThatNoSignatureCoversIsRefused() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer); + + Assert.DoesNotContain("_rels/extension.vsixmanifest.rels", SignedEntryNames(vsix)); + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Equal(DoesNotCover("relationship rIdLicense of /extension.vsixmanifest"), Check(vsix)); + } + + [Fact] + public void ARelationshipPartThatTheSignatureSignsWholeIsAccepted() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer, parts: ContentAndRelationshipParts(Manifest)); + + Assert.Contains("_rels/extension.vsixmanifest.rels", SignedEntryNames(vsix)); + Assert.Null(Check(vsix)); + } + + [Fact] + public void ARelationshipThatASelectorPicksByIdIsAccepted() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer, selectors: new[] { SelectLicenseRelationshipById() }); + + Assert.DoesNotContain("_rels/extension.vsixmanifest.rels", SignedEntryNames(vsix)); + Assert.Null(Check(vsix)); + } + + [Fact] + public void ARelationshipThatASelectorPicksByTypeIsAccepted() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer, selectors: new[] { new PackageRelationshipSelector(PartUri(Manifest), PackageRelationshipSelectorType.Type, TestRelationship) }); + + Assert.Null(Check(vsix)); + } + + [Fact] + public void ARelationshipAddedAfterSigningIsRefusedWhenSelectorsSignTheOthers() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer, selectors: new[] { SelectLicenseRelationshipById() }); + Assert.Null(Check(vsix)); + + // The selector picks rIdLicense only. The signature still verifies, and the new relationship has no cover. + AddRelationship(vsix, Manifest, "rIdExtra", TestRelationship, License); + + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Equal(DoesNotCover("relationship rIdExtra of /extension.vsixmanifest"), Check(vsix)); + } + + [Fact] + public void ARelationshipAddedToAPartThatHadNoneIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, Manifest, "rIdExtra", TestRelationship, License); + + // The relationship part is a new entry, and nothing covers it. + Assert.Contains("_rels/extension.vsixmanifest.rels", EntryNames(vsix)); + Assert.Equal(DoesNotCover("relationship rIdExtra of /extension.vsixmanifest"), Check(vsix)); + } + + [Fact] + public void ARelationshipAddedAfterSigningBreaksTheSignatureOfAWholeRelationshipPart() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer, parts: ContentAndRelationshipParts(Manifest)); + AddRelationship(vsix, Manifest, "rIdExtra", TestRelationship, License); + + Assert.Equal(NotValid(VerifyResult.InvalidSignature), Check(vsix)); + } + + [Fact] + public void ARelationshipOfASelectedTypeAddedAfterSigningBreaksTheSignature() + { + var vsix = UnsignedWithLicenseRelationship(); + Sign(vsix, TestCertificates.Installer, selectors: new[] { new PackageRelationshipSelector(PartUri(Manifest), PackageRelationshipSelectorType.Type, TestRelationship) }); + AddRelationship(vsix, Manifest, "rIdExtra", TestRelationship, License); + + // A selector by type signs every relationship of that type, and now there is one more. + Assert.Equal(NotValid(VerifyResult.InvalidSignature), Check(vsix)); + } + + [Fact] + public void AnExternalRelationshipAddedAfterSigningIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, Manifest, "rIdExternal", TestRelationship, "http://example.com/payload", TargetMode.External); + + Assert.Equal(DoesNotCover("relationship rIdExternal of /extension.vsixmanifest"), Check(vsix)); + } + + [Fact] + public void ACertificateRelationshipOnAnOrdinaryPartIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, Manifest, "rIdCertificate", CertificateRelationship, License); + + // Only the relationships of the origin part and the signature part are exempt, whatever their type. + Assert.Equal(DoesNotCover("relationship rIdCertificate of /extension.vsixmanifest"), Check(vsix)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ARelationshipPartForAPartThatDoesNotExistIsRefused(bool withRelationship) + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + var relationships = withRelationship + ? "" + : ""; + EditZip(vsix, zip => AddEntry(zip, "_rels/missing.bin.rels", + Bytes("" + relationships + ""))); + + Assert.Equal(DoesNotCover("/_rels/missing.bin.rels"), Check(vsix)); + } + + // ---- the origin relationship of the package ---- + + [Fact] + public void TheOriginRelationshipOfThePackageIsAcceptedWithNoSignatureOverIt() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + + // The signing API adds the origin relationship to the package after it signs. The package + // relationship part therefore exists, holds the origin relationship, and is not signed. + Assert.Contains("_rels/.rels", EntryNames(vsix)); + Assert.DoesNotContain("_rels/.rels", SignedEntryNames(vsix)); + using (var package = Package.Open(vsix, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Assert.Single(package.GetRelationships()); + Assert.Single(package.GetRelationshipsByType(OriginRelationship)); + } + + Assert.Null(Check(vsix)); + } + + [Fact] + public void APackageRelationshipNextToTheOriginRelationshipIsRefusedWhenNothingCoversIt() + { + var vsix = NewVsix(); + CreateUnsigned(vsix, package => package.CreateRelationship(PartUri(Manifest), TargetMode.Internal, TestRelationship, "rIdRoot")); + Sign(vsix, TestCertificates.Installer); + + Assert.Equal(DoesNotCover("relationship rIdRoot of /"), Check(vsix)); + } + + [Fact] + public void APackageRelationshipThatASelectorPicksIsAccepted() + { + var vsix = NewVsix(); + CreateUnsigned(vsix, package => package.CreateRelationship(PartUri(Manifest), TargetMode.Internal, TestRelationship, "rIdRoot")); + Sign(vsix, TestCertificates.Installer, selectors: new[] { new PackageRelationshipSelector(PartUri("/"), PackageRelationshipSelectorType.Id, "rIdRoot") }); + + // The selector picks rIdRoot. The origin relationship next to it needs no selector. + Assert.Null(Check(vsix)); + } + + [Fact] + public void ThePackageRelationshipPartThatTheSignatureSignsWholeIsAccepted() + { + var vsix = NewVsix(); + CreateUnsigned(vsix, package => package.CreateRelationship(PartUri(Manifest), TargetMode.Internal, TestRelationship, "rIdRoot")); + Sign(vsix, TestCertificates.Installer, parts: ContentAndRelationshipParts("/")); + + Assert.Contains("_rels/.rels", SignedEntryNames(vsix)); + Assert.Null(Check(vsix)); + } + + [Fact] + public void APackageRelationshipAddedAfterSigningIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, "/", "rIdRoot", TestRelationship, Manifest); + + Assert.Equal(DoesNotCover("relationship rIdRoot of /"), Check(vsix)); + } + + [Fact] + public void APackageRelationshipOfAnotherSignatureTypeIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + + // A relationship type under the signature namespace is not the origin type, so it is not exempt. + AddRelationship(vsix, "/", "rIdRoot", SignatureRelationshipPrefix + "other", Manifest); + + Assert.Equal(DoesNotCover("relationship rIdRoot of /"), Check(vsix)); + } + + [Fact] + public void ASecondOriginRelationshipThatPointsToAnotherPartIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, "/", "rIdOrigin2", OriginRelationship, Manifest); + + // The exemption is for the relationship that points to the origin part, and this one does not. + Assert.Equal(DoesNotCover("relationship rIdOrigin2 of /"), Check(vsix)); + } + + [Fact] + public void ASecondOriginRelationshipThatPointsToTheOriginPartIsRefusedByOpc() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, "/", "rIdOrigin2", OriginRelationship, OriginPart); + + Assert.StartsWith(FailedToRead, Check(vsix)); + } + + [Fact] + public void AnOriginRelationshipOnAnOrdinaryPartIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + AddRelationship(vsix, Manifest, "rIdOrigin", OriginRelationship, OriginPart); + + // The exemption is for the relationship of the package, not for a relationship of a part. + Assert.Equal(DoesNotCover("relationship rIdOrigin of /extension.vsixmanifest"), Check(vsix)); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixSignatureTests.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixSignatureTests.cs new file mode 100644 index 0000000..3ce64df --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixSignatureTests.cs @@ -0,0 +1,222 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.IO.Packaging; +using System.Security.Cryptography.X509Certificates; +using static PlanViewer.Ssms.Installer.Tests.VsixFixture; +using InstallerProgram = PlanViewer.Ssms.Installer.Program; + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// The first steps of Program.CheckVsix, which are about the signature itself: it passes a VSIX +/// with exactly one valid signature, made with the certificate of the installer, and it refuses a file +/// with no signature, with two, with the certificate of another signer, or with a part that no longer +/// matches its signature. The steps run in that order and stop at the first failure. +/// +/// The VSIX files are built by at test time and signed with certificates that +/// makes in memory. +/// +public class VsixSignatureTests : VsixTestBase +{ + [Fact] + public void AVsixSignedWithTheInstallerCertificateThatCoversEveryPartIsAccepted() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + + // A control is worth something only when the signature really covers every content part. + var signed = SignedEntryNames(vsix); + foreach (var name in DefaultPartNames) + Assert.Contains(EntryName(name), signed); + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + + Assert.Null(Check(vsix)); + } + + [Fact] + public void TheCertificateMayBeInItsOwnPartInsteadOfTheSignaturePart() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.InCertificatePart); + + Assert.Single(EntryNames(vsix), n => n.EndsWith(".cer", StringComparison.OrdinalIgnoreCase)); + Assert.Null(Check(vsix)); + } + + [Fact] + public void AnUnsignedVsixIsRefused() + { + var vsix = NewVsix(); + CreateUnsigned(vsix); + + Assert.Equal(NotSigned, Check(vsix)); + } + + [Fact] + public void AVsixSignedWithAnotherCertificateIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Other); + + Assert.Equal(DifferentCertificate, Check(vsix)); + + // The file is sound. It is refused only because the installer has another certificate. + Assert.Equal(VerifyResult.Success, VerifySignature(vsix)); + Assert.Null(Check(vsix, TestCertificates.Other)); + } + + [Fact] + public void TheWholeCertificateMustMatchNotOnlyItsThumbprint() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + + // A certificate with the thumbprint of the signer and the bytes of another certificate. + var twin = new ThumbprintTwin(TestCertificates.Other, TestCertificates.Installer); + Assert.Equal(TestCertificates.Installer.GetCertHashString(), twin.GetCertHashString()); + + Assert.Equal(DifferentCertificate, InstallerProgram.CheckVsix(vsix, twin)); + Assert.Null(Check(vsix)); + } + + [Fact] + public void TheCertificateIsComparedBeforeTheSignatureIsVerified() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Other); + EditZip(vsix, zip => ReplaceEntry(zip, EntryName(Assembly), Bytes("changed"))); + + // The signature is broken as well, but the certificate of the signer is not the certificate + // of the installer, and that is the reason that comes back. + Assert.Equal(VerifyResult.InvalidSignature, VerifySignature(vsix)); + Assert.Equal(DifferentCertificate, Check(vsix)); + } + + [Fact] + public void ASignatureThatHoldsNoCertificateIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.NotEmbedded); + + Assert.Equal(NotValid(VerifyResult.CertificateRequired), Check(vsix)); + } + + [Fact] + public void AFileWithTwoSignaturesIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + Sign(vsix, TestCertificates.Other); + + Assert.Equal(MoreThanOneSignature, Check(vsix)); + } + + [Fact] + public void TwoSignaturesFromTheInstallerCertificateAreRefusedToo() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + Sign(vsix, TestCertificates.Installer); + + Assert.Equal(MoreThanOneSignature, Check(vsix)); + } + + [Fact] + public void APartThatChangedAfterSigningIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => ReplaceEntry(zip, EntryName(Assembly), Bytes("MZ and then something else"))); + + Assert.Equal(NotValid(VerifyResult.InvalidSignature), Check(vsix)); + } + + [Fact] + public void APartThatWasRemovedAfterSigningIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, zip => zip.GetEntry(EntryName(Assembly))!.Delete()); + + Assert.Equal(NotValid(VerifyResult.ReferenceNotFound), Check(vsix)); + } + + [Fact] + public void ANewContentTypeForASignedPartIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + + // [Content_Types].xml is not signed, but the signature covers the content type of each part it signs. + EditZip(vsix, zip => ReplaceInEntry(zip, ContentTypesEntry, "ContentType=\"text/xml\"", "ContentType=\"text/evil\"")); + + Assert.Equal(NotValid(VerifyResult.InvalidSignature), Check(vsix)); + } + + [Fact] + public void ASignatureValueThatChangedIsRefused() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer); + EditZip(vsix, FlipSignatureValue); + + Assert.Equal(NotValid(VerifyResult.InvalidSignature), Check(vsix)); + } + + [Fact] + public void ACertificatePartThatWasReplacedWithAnotherCertificateMakesTheSignerDifferent() + { + var vsix = NewVsix(); + CreateSigned(vsix, TestCertificates.Installer, CertificateEmbeddingOption.InCertificatePart); + EditZip(vsix, zip => ReplaceEntry(zip, EntryWithExtension(zip, ".cer"), TestCertificates.Other.RawData)); + + Assert.Equal(DifferentCertificate, Check(vsix)); + } + + [Fact] + public void AFileThatIsNotAZipIsRefused() + { + var vsix = NewVsix(); + File.WriteAllText(vsix, "This is not a ZIP file."); + + Assert.StartsWith(FailedToRead, Check(vsix)); + } + + [Fact] + public void AMissingFileIsRefused() + { + Assert.StartsWith(FailedToRead, Check(NewVsix())); + } + + [Fact] + public void AZipFileThatIsNotAPackageHasNoSignatureAndIsRefused() + { + var vsix = NewVsix(); + using (var stream = new FileStream(vsix, FileMode.Create)) + using (var zip = new ZipArchive(stream, ZipArchiveMode.Create)) + AddEntry(zip, "readme.txt", Bytes("A ZIP file with no content types.")); + + Assert.Equal(NotSigned, Check(vsix)); + } + + /// + /// A certificate that reports the thumbprint of one certificate and holds the bytes of another. No + /// real certificate is like this, because the thumbprint is a hash of the bytes. The twin stands for + /// a check that compares thumbprints only, and it shows that CheckVsix compares the whole certificate. + /// + sealed class ThumbprintTwin : X509Certificate + { + readonly X509Certificate thumbprintOf; + + public ThumbprintTwin(X509Certificate bytesOf, X509Certificate thumbprintOf) + : base(bytesOf.GetRawCertData()) + { + this.thumbprintOf = thumbprintOf; + } + + public override byte[] GetCertHash() => thumbprintOf.GetCertHash(); + + public override string GetCertHashString() => thumbprintOf.GetCertHashString(); + } +} diff --git a/tests/PlanViewer.Ssms.Installer.Tests/VsixTestBase.cs b/tests/PlanViewer.Ssms.Installer.Tests/VsixTestBase.cs new file mode 100644 index 0000000..d5bc0a6 --- /dev/null +++ b/tests/PlanViewer.Ssms.Installer.Tests/VsixTestBase.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.IO.Packaging; +using System.Security.Cryptography.X509Certificates; +using Xunit.Sdk; +using Xunit.v3; +using InstallerProgram = PlanViewer.Ssms.Installer.Program; + +// The tests are small and quick. They share only the two certificates, so turning parallelism +// off costs nothing and removes any doubt about signing from several threads at once. +[assembly: Parallelization(Mode = ParallelMode.None)] + +namespace PlanViewer.Ssms.Installer.Tests; + +/// +/// The base of the tests of Program.CheckVsix. Each test gets a private temp folder for its VSIX +/// files, and the folder is deleted when the test ends. It also holds the reasons that CheckVsix +/// returns, so that a test states the reason it expects. +/// +public abstract class VsixTestBase : IDisposable +{ + // The reasons of CheckVsix. It returns null when it accepts the file. + protected const string NotSigned = "the file is not signed"; + protected const string MoreThanOneSignature = "the file has more than one signature"; + protected const string DifferentCertificate = "the file is signed with a different certificate than this installer"; + + // The reason starts like this when System.IO.Packaging or the ZIP reader refuses the file, and the + // rest of it is the message of the .NET exception. That message is in the language of Windows, so + // the tests match only this start. + protected const string FailedToRead = "the installer failed to read the file ("; + + protected static string NotValid(VerifyResult result) => $"the signature is not valid ({result})"; + + protected static string DoesNotCover(string what) => "the signature does not cover " + what; + + readonly string folder = Path.Combine(Path.GetTempPath(), "PlanViewerSsmsInstallerTests-" + Guid.NewGuid().ToString("N")); + + protected VsixTestBase() + { + Directory.CreateDirectory(folder); + } + + /// The path for a new VSIX file. It does not exist yet. + protected string NewVsix() => Path.Combine(folder, Guid.NewGuid().ToString("N") + ".vsix"); + + /// + /// Runs the check of the installer on the file. The installer certificate is the one the tests + /// treat as the certificate of the signed installer, unless the test passes another. It is passed + /// as the installer holds it: a plain X509Certificate with no private key. + /// + protected static string? Check(string vsix, X509Certificate2? installerCertificate = null) + { + return InstallerProgram.CheckVsix(vsix, TestCertificates.AsInstallerCertificate(installerCertificate ?? TestCertificates.Installer)); + } + + /// The verdict of System.IO.Packaging on the signature alone, with none of the coverage rules of the installer. + protected static VerifyResult VerifySignature(string vsix) + { + using (var package = Package.Open(vsix, FileMode.Open, FileAccess.Read, FileShare.Read)) + return new PackageDigitalSignatureManager(package).VerifySignatures(false); + } + + public void Dispose() + { + try { Directory.Delete(folder, true); } + catch (IOException) { } + catch (UnauthorizedAccessException) { } + } +} From 32bad7cc41aa022e5e643f8dec8f2c5d5e687204 Mon Sep 17 00:00:00 2001 From: Erik Darling <2136037+erikdarlingdata@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:45:48 -0400 Subject: [PATCH 2/2] List the installer test project in the Dependabot config dependabot.yml names every project directory, because PlanViewer.sln leaves some out and a solution-level scan would skip them. The new net472 test project is not in the solution either. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_019tS6P95Dtzs4aeMpb1xqzX --- .github/dependabot.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 724be32..9e5a437 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,7 @@ version: 2 # Coverage note: PlanViewer.sln does NOT contain server/PlanShare, PlanViewer.Ssms, -# or PlanViewer.Ssms.Installer, so any solution-level scan silently skips them. +# PlanViewer.Ssms.Installer or its tests, so any solution-level scan silently skips them. # Every project directory is therefore listed explicitly below. If a new .csproj # is added anywhere, add its directory here too. # @@ -20,6 +20,7 @@ updates: - "/src/PlanViewer.Ssms" - "/src/PlanViewer.Ssms.Installer" - "/tests/PlanViewer.Core.Tests" + - "/tests/PlanViewer.Ssms.Installer.Tests" - "/server/PlanShare" schedule: interval: weekly