Skip to content
Open
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
68 changes: 65 additions & 3 deletions src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,72 @@ private static bool IsSatisfyingAssembly(AssemblyName requiredAssemblyName, stri
return false;
}

AssemblyName asmToLoadName = AssemblyName.GetAssemblyName(assemblyPath);
return IsSatisfyingAssembly(requiredAssemblyName, AssemblyName.GetAssemblyName(assemblyPath));
}

// Internal (rather than private) purely so it can be unit tested with constructed
// AssemblyName instances; it has no file-system dependency of its own.
internal static bool IsSatisfyingAssembly(AssemblyName requiredAssemblyName, AssemblyName asmToLoadName)
{
// The simple name must match (case-insensitively, as assembly names are).
if (!string.Equals(asmToLoadName.Name, requiredAssemblyName.Name, StringComparison.OrdinalIgnoreCase))
{
return false;
}

// The candidate must be at least the requested version. We still accept newer
// versions, since shared framework and $PSHOME assemblies are generally
// forward-compatible via the runtime's binding.
if (asmToLoadName.Version < requiredAssemblyName.Version)
{
return false;
}

// The strong-name identity must match. Previously only the simple name and version
// were compared, so a same-named assembly with a *different* public key token (i.e.
// a genuinely different assembly) was treated as a drop-in replacement and would then
// fail at runtime with a FileLoadException/TypeLoadException. Requiring the public key
// token to match means we only short-circuit to a $PSHOME/Common assembly that can
// actually satisfy the reference; otherwise we fall through and let the default load
// context resolve it with its own (laxer) rules.
if (!PublicKeyTokensMatch(requiredAssemblyName, asmToLoadName))
{
return false;
}

// The culture must match so we never substitute a satellite resource assembly for the
// neutral one (or vice versa).
return string.Equals(
asmToLoadName.CultureName ?? string.Empty,
requiredAssemblyName.CultureName ?? string.Empty,
StringComparison.OrdinalIgnoreCase);
}

private static bool PublicKeyTokensMatch(AssemblyName requiredAssemblyName, AssemblyName candidateAssemblyName)
{
byte[] requiredToken = requiredAssemblyName.GetPublicKeyToken();

// A reference to a non-strong-named assembly imposes no public key token requirement.
if (requiredToken is null || requiredToken.Length == 0)
{
return true;
}

byte[] candidateToken = candidateAssemblyName.GetPublicKeyToken();
if (candidateToken is null || candidateToken.Length != requiredToken.Length)
{
return false;
}

for (int i = 0; i < requiredToken.Length; i++)
{
if (requiredToken[i] != candidateToken[i])
{
return false;
}
}

return string.Equals(asmToLoadName.Name, requiredAssemblyName.Name, StringComparison.OrdinalIgnoreCase)
&& asmToLoadName.Version >= requiredAssemblyName.Version;
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@
<DefineConstants>$(DefineConstants);CoreCLR</DefineConstants>
</PropertyGroup>

<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleTo">
<_Parameter1>Microsoft.PowerShell.EditorServices.Test</_Parameter1>
</AssemblyAttribute>
</ItemGroup>

<ItemGroup>
<PackageReference Include="PowerShellStandard.Library" PrivateAssets="all" />
<PackageReference Include="System.IO.Pipes.AccessControl" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
<ProjectReference Include="..\PowerShellEditorServices.Test.Shared\PowerShellEditorServices.Test.Shared.csproj" />
</ItemGroup>

<!-- The Hosting assembly (and thus PsesLoadContext) only exists on .NET Core. -->
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<ProjectReference Include="..\..\src\PowerShellEditorServices.Hosting\PowerShellEditorServices.Hosting.csproj" />
</ItemGroup>

<!-- PowerShell 7.4.x -->
<ItemGroup Condition=" '$(TargetFramework)' == 'net8.0' ">
<PackageReference Include="Microsoft.PowerShell.SDK" />
Expand Down
134 changes: 134 additions & 0 deletions test/PowerShellEditorServices.Test/Session/PsesLoadContextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

#if CoreCLR

using System;
using System.Globalization;
using System.Reflection;
using Microsoft.PowerShell.EditorServices.Hosting;
using Xunit;

namespace PowerShellEditorServices.Test.Session
{
[Trait("Category", "PsesLoadContext")]
public class PsesLoadContextTests
{
// Two distinct, realistic public key tokens: Newtonsoft.Json's and the ECMA/Microsoft one.
private static readonly byte[] s_tokenA = { 0x30, 0xad, 0x4f, 0xe6, 0xb2, 0xa6, 0xae, 0xed };
private static readonly byte[] s_tokenB = { 0xb0, 0x3f, 0x5f, 0x7f, 0x11, 0xd5, 0x0a, 0x3a };

private static AssemblyName MakeName(
string name,
string version = "1.0.0.0",
byte[] publicKeyToken = null,
string culture = "")
{
AssemblyName assemblyName = new(name)
{
Version = new Version(version),
CultureInfo = string.IsNullOrEmpty(culture)
? CultureInfo.InvariantCulture
: new CultureInfo(culture),
};

assemblyName.SetPublicKeyToken(publicKeyToken);
return assemblyName;
}

[Fact]
public void IsSatisfyingWhenIdentityMatchesExactly()
{
AssemblyName required = MakeName("Contoso.Lib", "2.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("Contoso.Lib", "2.0.0.0", s_tokenA);

Assert.True(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsSatisfyingWhenCandidateVersionIsNewer()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("Contoso.Lib", "2.5.0.0", s_tokenA);

Assert.True(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsNotSatisfyingWhenCandidateVersionIsOlder()
{
AssemblyName required = MakeName("Contoso.Lib", "2.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);

Assert.False(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsNotSatisfyingWhenSimpleNameDiffers()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("Fabrikam.Lib", "1.0.0.0", s_tokenA);

Assert.False(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsSatisfyingWhenSimpleNameDiffersOnlyByCase()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("contoso.lib", "1.0.0.0", s_tokenA);

Assert.True(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

// This is the core fix: matching name and version but a different strong-name identity
// must NOT be treated as a drop-in replacement, since binding to it would fail at runtime.
[Fact]
public void IsNotSatisfyingWhenPublicKeyTokenDiffers()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("Contoso.Lib", "1.0.0.0", s_tokenB);

Assert.False(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsNotSatisfyingWhenRequiredIsStrongNamedButCandidateIsNot()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);
AssemblyName candidate = MakeName("Contoso.Lib", "1.0.0.0", publicKeyToken: null);

Assert.False(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

// A reference to a non-strong-named assembly imposes no public key token requirement.
[Fact]
public void IsSatisfyingWhenRequiredIsNotStrongNamedRegardlessOfCandidateToken()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", publicKeyToken: null);
AssemblyName candidate = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA);

Assert.True(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsNotSatisfyingWhenCultureDiffers()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA, culture: "");
AssemblyName candidate = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA, culture: "fr");

Assert.False(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}

[Fact]
public void IsSatisfyingWhenCultureMatches()
{
AssemblyName required = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA, culture: "fr");
AssemblyName candidate = MakeName("Contoso.Lib", "1.0.0.0", s_tokenA, culture: "fr");

Assert.True(PsesLoadContext.IsSatisfyingAssembly(required, candidate));
}
}
}

#endif
Loading