diff --git a/ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.cs b/ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.cs new file mode 100644 index 000000000..2630cfdfd --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/UniversalAssemblyResolverTests.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.IO; + +using ICSharpCode.Decompiler.Metadata; + +using NUnit.Framework; + +namespace ICSharpCode.Decompiler.Tests +{ + /// + /// The GAC layout is the same on every platform ILSpy runs on, so these tests build one in a + /// temporary directory instead of requiring a .NET Framework installation. + /// + [TestFixture] + public class UniversalAssemblyResolverTests + { + string gacDirectory; + + [SetUp] + public void SetUp() + { + gacDirectory = Path.Combine(Path.GetTempPath(), "ILSpyGacTest_" + Path.GetRandomFileName()); + Directory.CreateDirectory(gacDirectory); + } + + [TearDown] + public void TearDown() + { + if (Directory.Exists(gacDirectory)) + Directory.Delete(gacDirectory, recursive: true); + } + + string AddAssembly(string name, string version, string publicKeyToken, string culture = "") + { + string directory = Path.Combine(gacDirectory, name, $"v4.0_{version}_{culture}_{publicKeyToken}"); + Directory.CreateDirectory(directory); + string file = Path.Combine(directory, name + ".dll"); + File.WriteAllBytes(file, new byte[0]); + return file; + } + + static IAssemblyReference Reference(string name, string version, string publicKeyToken) + { + return AssemblyNameReference.Parse($"{name}, Version={version}, Culture=neutral, PublicKeyToken={publicKeyToken}"); + } + + [Test] + public void HigherReferenceVersionResolvesToTheVersionInstalledInTheGac() + { + // The 4.8 reference assembly of System.IO.Compression is 4.2.0.0, but the GAC only + // ever holds 4.0.0.0; the runtime unifies the reference onto the installed version. + string file = AddAssembly("System.IO.Compression", "4.0.0.0", "b77a5c561934e089"); + + var reference = Reference("System.IO.Compression", "4.2.0.0", "b77a5c561934e089"); + + Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory), + Is.EqualTo(file)); + } + + [Test] + public void UnificationPicksTheHighestInstalledVersion() + { + AddAssembly("Microsoft.Build.Framework", "15.1.0.0", "b03f5f7f11d50a3a"); + string expected = AddAssembly("Microsoft.Build.Framework", "15.2.0.0", "b03f5f7f11d50a3a"); + + var reference = Reference("Microsoft.Build.Framework", "15.0.0.0", "b03f5f7f11d50a3a"); + + Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory), + Is.EqualTo(expected)); + } + + [Test] + public void UnificationDoesNotCrossMajorVersions() + { + // Microsoft.Build.Framework 4.0.0.0 and 15.x are different products sharing a name. + AddAssembly("Microsoft.Build.Framework", "4.0.0.0", "b03f5f7f11d50a3a"); + + var reference = Reference("Microsoft.Build.Framework", "15.0.0.0", "b03f5f7f11d50a3a"); + + Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory), + Is.Null); + } + + [Test] + public void UnificationRequiresAMatchingPublicKeyToken() + { + AddAssembly("System.IO.Compression", "4.0.0.0", "31bf3856ad364e35"); + + var reference = Reference("System.IO.Compression", "4.2.0.0", "b77a5c561934e089"); + + Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory), + Is.Null); + } + + [Test] + public void SatelliteAssembliesAreNotUsedForUnification() + { + AddAssembly("System.IO.Compression", "4.0.0.0", "b77a5c561934e089", culture: "de"); + + var reference = Reference("System.IO.Compression", "4.2.0.0", "b77a5c561934e089"); + + Assert.That(UniversalAssemblyResolver.FindUnifiedAssemblyInGacFolder(reference, "v4.0_", gacDirectory), + Is.Null); + } + } +} diff --git a/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs b/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs index b4a1f0bc9..afee1ed2b 100644 --- a/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs +++ b/ICSharpCode.Decompiler/Metadata/UniversalAssemblyResolver.cs @@ -788,9 +788,67 @@ namespace ICSharpCode.Decompiler.Metadata } } + // An exact version match is always preferred, so unification is a separate pass over + // the whole GAC rather than a fallback within one folder. + for (int i = 0; i < gac_paths.Count; i++) + { + for (int j = 0; j < gacs.Length; j++) + { + var gac = Path.Combine(gac_paths[i], gacs[j]); + var file = FindUnifiedAssemblyInGacFolder(reference, prefixes[i], gac); + if (file != null) + return file; + } + } + return null; } + /// + /// Finds an assembly whose version differs from the requested one, because the runtime + /// unifies references to in-box assemblies onto whatever version the installed framework + /// carries. The versions genuinely differ: the .NET Framework 4.7.2/4.8 reference + /// assemblies of about a hundred assemblies (System.IO.Compression is 4.2.0.0, System.Runtime + /// is 4.1.2.0, ...) are higher than the 4.0.0.0 implementations in the GAC, which is the + /// only version ever installed there (issue #2080). + /// + /// Unification is approximated by the highest installed version that shares the major + /// version and the public key token of the reference. The major version is what separates + /// assemblies that share a name but are different products, e.g. Microsoft.Build.Framework + /// 4.0.0.0 and 15.x. + /// + internal static string? FindUnifiedAssemblyInGacFolder(IAssemblyReference reference, string prefix, string gac) + { + var requestedVersion = reference.Version; + if (requestedVersion == null || reference.PublicKeyToken == null) + return null; + string assemblyDirectory = Path.Combine(gac, reference.Name); + if (!Directory.Exists(assemblyDirectory)) + return null; + // The folder name is "{prefix}{version}_{culture}_{publicKeyToken}"; the culture of a + // non-satellite assembly is empty, which leaves the two underscores adjacent. + string suffix = "__" + reference.PublicKeyToken.ToHexString(8); + string? bestFile = null; + Version? bestVersion = null; + foreach (var candidate in Directory.EnumerateDirectories(assemblyDirectory, prefix + "*" + suffix)) + { + string folderName = Path.GetFileName(candidate); + string versionText = folderName.Substring(prefix.Length, folderName.Length - prefix.Length - suffix.Length); + if (!Version.TryParse(versionText, out var version)) + continue; + if (version.Major != requestedVersion.Major) + continue; + if (bestVersion != null && version <= bestVersion) + continue; + string file = Path.Combine(candidate, reference.Name + ".dll"); + if (!File.Exists(file)) + continue; + bestFile = file; + bestVersion = version; + } + return bestFile; + } + static string GetAssemblyFile(IAssemblyReference reference, string prefix, string gac) { var gac_folder = new StringBuilder()