Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -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.
#
Expand All @@ -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
Expand Down
36 changes: 36 additions & 0 deletions .github/workflows/ssms-installer-tests.yml
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@
<Reference Include="System.IO.Compression" />
</ItemGroup>

<ItemGroup>
<!-- Lets tests/PlanViewer.Ssms.Installer.Tests call the internal signature check. -->
<InternalsVisibleTo Include="PlanViewer.Ssms.Installer.Tests" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<!-- The installer targets .NET Framework 4.7.2 and its signature check uses
System.IO.Packaging.PackageDigitalSignatureManager, which exists only on .NET Framework.
Its tests have to run on .NET Framework too, so this project is not in PlanViewer.sln:
ci.yml builds the solution on ubuntu-latest and cannot run net472 tests.
.github/workflows/ssms-installer-tests.yml builds and runs this project on Windows. -->
<TargetFramework>net472</TargetFramework>
<OutputType>Exe</OutputType>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>

<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<TestingPlatformDotnetTestSupport>true</TestingPlatformDotnetTestSupport>

<!-- The same 15 minute session timeout as PlanViewer.Core.Tests, so that a bare `dotnet test`
cannot hang for good. -->
<TestingPlatformCommandLineArguments>--timeout 15m</TestingPlatformCommandLineArguments>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.Testing.Extensions.HangDump" />
<PackageReference Include="xunit.v3" />
</ItemGroup>

<ItemGroup>
<Reference Include="WindowsBase" />
<Reference Include="System.IO.Compression" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\PlanViewer.Ssms.Installer\PlanViewer.Ssms.Installer.csproj" />
</ItemGroup>

<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>

</Project>
41 changes: 41 additions & 0 deletions tests/PlanViewer.Ssms.Installer.Tests/TestCertificates.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using System;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;

namespace PlanViewer.Ssms.Installer.Tests;

/// <summary>
/// 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.
/// </summary>
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));
}
}

/// <summary>The certificate that the tests treat as the certificate of the signed installer.</summary>
public static X509Certificate2 Installer { get; } = Create("Test installer");

/// <summary>A second certificate, for a VSIX that another signer signed.</summary>
public static X509Certificate2 Other { get; } = Create("Other signer");

/// <summary>
/// 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.
/// </summary>
public static X509Certificate AsInstallerCertificate(X509Certificate2 certificate)
{
return new X509Certificate(certificate.Export(X509ContentType.Cert));
}
}
176 changes: 176 additions & 0 deletions tests/PlanViewer.Ssms.Installer.Tests/VsixCaseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
using System.IO.Packaging;
using static PlanViewer.Ssms.Installer.Tests.VsixFixture;

namespace PlanViewer.Ssms.Installer.Tests;

/// <summary>
/// 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.
/// </summary>
public class VsixCaseTests : VsixTestBase
{
const string EmptyContentTypes = "<Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\" />";

[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);
}
}
Loading
Loading