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
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) { }
+ }
+}