diff --git a/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs b/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs
index da6588f7e..77de15bd1 100644
--- a/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs
+++ b/src/PowerShellEditorServices.Hosting/Internal/PsesLoadContext.cs
@@ -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;
}
}
}
diff --git a/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj b/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj
index 1c30a93df..a41911e14 100644
--- a/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj
+++ b/src/PowerShellEditorServices.Hosting/PowerShellEditorServices.Hosting.csproj
@@ -11,6 +11,12 @@
$(DefineConstants);CoreCLR
+
+
+ <_Parameter1>Microsoft.PowerShell.EditorServices.Test
+
+
+
diff --git a/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj b/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj
index 6161a113e..0566b8cf2 100644
--- a/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj
+++ b/test/PowerShellEditorServices.Test/PowerShellEditorServices.Test.csproj
@@ -16,6 +16,11 @@
+
+
+
+
+
diff --git a/test/PowerShellEditorServices.Test/Session/PsesLoadContextTests.cs b/test/PowerShellEditorServices.Test/Session/PsesLoadContextTests.cs
new file mode 100644
index 000000000..ace268854
--- /dev/null
+++ b/test/PowerShellEditorServices.Test/Session/PsesLoadContextTests.cs
@@ -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