diff --git a/.agents/skills/msbuild-loader-netframework/SKILL.md b/.agents/skills/msbuild-loader-netframework/SKILL.md index e8e3bf5..0058824 100644 --- a/.agents/skills/msbuild-loader-netframework/SKILL.md +++ b/.agents/skills/msbuild-loader-netframework/SKILL.md @@ -19,16 +19,39 @@ and cross-cutting conventions, see `AGENTS.md`. ## Assembly-resolution handler - `s_registeredHandler` is a static `ResolveEventHandler`; `IsRegistered` is `s_registeredHandler != null`. +- `RegisterMSBuildPathsInternally` parses an MSBuild configuration file (preferring + `amd64\MSBuild.exe.config`, falling back to `MSBuild.exe.config` in the registered path). + The config policy is read once. The selected config path is made absolute during + registration, so relative `codeBase` paths stay anchored to that config directory + even if the process working directory later changes. - `RegisterMSBuildPathsInternally` stores the handler in the static field before subscribing to `AppDomain.CurrentDomain.AssemblyResolve`; the event subscription keeps the delegate alive, while the field tracks registration state. - `AssemblyResolve` can fire repeatedly for the same assembly; results are cached - in `loadedAssemblies` keyed by `AssemblyName.FullName`. + in `loadedAssemblies` under the original request's `AssemblyName.FullName`, the + effective identity for config candidates, and the loaded assembly's full identity. - Resolution is explicitly not thread-safe; every cache lookup/load runs under `lock (loadedAssemblies)`. -- Handler path: parse `eventArgs.Name` with `new AssemblyName(eventArgs.Name)`; - for each registered search path, if `\.dll` exists, return - `Assembly.LoadFrom(targetAssembly)`. +- Handler path: parse `eventArgs.Name` with `new AssemblyName(eventArgs.Name)`. + - Config-first ordering: + - Apply `qualifyAssembly` policy if the request has a partial name. + - Match name, token, and culture. Compare configured `processorArchitecture` + with the request when it specifies one; otherwise defer that check to the + candidate file's manifest. + - If a valid `` applies, map to the `newVersion`. + - If a `` matches the effective version (whether redirected or standalone), + normalize its local path or `file://` URI, resolving relative paths against + the config directory. + - Inspect the candidate file's manifest with `AssemblyName.GetAssemblyName` + and compare its identity, including any required architecture, before + calling `Assembly.LoadFrom`. Cache the returned assembly; its identity + is not revalidated after loading. + - A missing/malformed config yields an empty policy without masking other failures. + - Fallback: + - If the config does not resolve the assembly or load fails (e.g. `FileNotFoundException`), + fall back to search-path probing (the old/custom layout behavior). + - For each registered search path, if `\.dll` exists, return + `Assembly.LoadFrom(targetAssembly)`. - Search paths come from `RegisterMSBuildPath(...)`, or from `RegisterInstance(...)` as `instance.MSBuildPath` plus the VS NuGet path when it exists. diff --git a/src/MSBuildLocator.Tests/AssemblyResolutionRunner.cs b/src/MSBuildLocator.Tests/AssemblyResolutionRunner.cs new file mode 100644 index 0000000..42191fc --- /dev/null +++ b/src/MSBuildLocator.Tests/AssemblyResolutionRunner.cs @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.Linq; +using System.Reflection; + +namespace Microsoft.Build.Locator.Tests +{ + /// + /// Drives a real registration inside a child AppDomain. Registration installs a process-wide + /// handler and cannot be undone, so it must not happen in the + /// AppDomain running the tests. Statics and assembly loads are per-AppDomain, so a child domain gives + /// each test a clean registration that disappears when the domain unloads. + /// + public sealed class AssemblyResolutionRunner : MarshalByRefObject + { + /// Registers , returning on success. + public string Register(string[] msbuildSearchPaths) + { + try + { + MSBuildLocator.RegisterMSBuildPath(msbuildSearchPaths); + return null; + } + catch (Exception e) + { + return e.ToString(); + } + } + + public string GetEnvironmentVariable(string name) => Environment.GetEnvironmentVariable(name); + + /// Loads the way any consumer of MSBuild would. + public AssemblyLoadResult Load(string assemblyName) + { + try + { + Assembly assembly = Assembly.Load(assemblyName); + return new AssemblyLoadResult + { + Succeeded = true, + FullName = assembly.GetName().FullName, + Location = assembly.Location, + LoadedCount = CountLoaded(assembly.GetName().Name) + }; + } + catch (Exception e) + { + return new AssemblyLoadResult { Error = e.ToString() }; + } + } + + /// + /// Loads two identities that policy binds to the same file, reporting whether the second request + /// reused the assembly the first one loaded. + /// + public AssemblyLoadResult LoadTwice(string firstAssemblyName, string secondAssemblyName) + { + try + { + Assembly first = Assembly.Load(firstAssemblyName); + Assembly second = Assembly.Load(secondAssemblyName); + + return new AssemblyLoadResult + { + Succeeded = true, + FullName = second.GetName().FullName, + Location = second.Location, + LoadedCount = CountLoaded(first.GetName().Name), + SameInstance = ReferenceEquals(first, second) + }; + } + catch (Exception e) + { + return new AssemblyLoadResult { Error = e.ToString() }; + } + } + + private static int CountLoaded(string simpleName) => AppDomain.CurrentDomain.GetAssemblies() + .Count(a => string.Equals(a.GetName().Name, simpleName, StringComparison.OrdinalIgnoreCase)); + } + + /// The outcome of a load attempt, marshaled back to the AppDomain running the test. + [Serializable] + public sealed class AssemblyLoadResult + { + public bool Succeeded { get; set; } + + public string FullName { get; set; } + + public string Location { get; set; } + + /// Number of assemblies with the loaded simple name in the child AppDomain. + public int LoadedCount { get; set; } + + /// Whether two requested identities resolved to the same instance. + public bool SameInstance { get; set; } + + public string Error { get; set; } + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/FakeVisualStudioInstall.cs b/src/MSBuildLocator.Tests/FakeVisualStudioInstall.cs new file mode 100644 index 0000000..9541bfd --- /dev/null +++ b/src/MSBuildLocator.Tests/FakeVisualStudioInstall.cs @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System.IO; + +namespace Microsoft.Build.Locator.Tests +{ + /// + /// A directory layout shaped like a Visual Studio installation that relocates MSBuild dependencies out + /// of the MSBuild bin directory, without requiring Visual Studio to be installed. + /// + internal sealed class FakeVisualStudioInstall + { + /// Relative path from the canonical amd64 config directory back to the installation root. + public const string Amd64ToRoot = @"..\..\..\.."; + + private FakeVisualStudioInstall(string root) + { + Root = root; + SharedAssemblies = Path.Combine(root, "SharedAssemblies"); + Bin = Path.Combine(root, "MSBuild", "Current", "Bin"); + Amd64 = Path.Combine(Bin, "amd64"); + } + + /// Root of the installation, the equivalent of C:\Program Files\Microsoft Visual Studio\18\Preview. + public string Root { get; } + + /// Directory holding assemblies shared by the installation rather than deployed beside MSBuild. + public string SharedAssemblies { get; } + + /// The 32-bit MSBuild bin directory. + public string Bin { get; } + + /// The 64-bit MSBuild bin directory, which holds the canonical config. + public string Amd64 { get; } + + public static FakeVisualStudioInstall Create(string root) + { + var install = new FakeVisualStudioInstall(root); + + Directory.CreateDirectory(install.SharedAssemblies); + Directory.CreateDirectory(install.Amd64); + + // Only the existence of MSBuild.exe matters to config discovery, so an empty file is enough. Its + // missing version resource reads as version 0, which also exercises the pre-17.1 compatibility path. + File.WriteAllText(Path.Combine(install.Bin, "MSBuild.exe"), string.Empty); + File.WriteAllText(Path.Combine(install.Amd64, "MSBuild.exe"), string.Empty); + + return install; + } + + /// Writes the canonical amd64\MSBuild.exe.config around . + public string WriteAmd64Config(string bindingContent) => WriteConfig(Amd64, ConfigXml(bindingContent)); + + /// Writes the base MSBuild.exe.config around . + public string WriteBinConfig(string bindingContent) => WriteConfig(Bin, ConfigXml(bindingContent)); + + /// Writes arbitrary content as the canonical config, for malformed-config tests. + public string WriteRawAmd64Config(string content) => WriteConfig(Amd64, content); + + public static string ConfigXml(string bindingContent) => $@" + + + +{bindingContent} + + +"; + + private static string WriteConfig(string directory, string content) + { + string path = Path.Combine(directory, "MSBuild.exe.config"); + File.WriteAllText(path, content); + return path; + } + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/FixtureAssembly.cs b/src/MSBuildLocator.Tests/FixtureAssembly.cs new file mode 100644 index 0000000..e45c3c6 --- /dev/null +++ b/src/MSBuildLocator.Tests/FixtureAssembly.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Emit; + +namespace Microsoft.Build.Locator.Tests +{ + /// + /// Emits strong-named assemblies for tests that need an assembly no probing path can find. Emitting + /// keeps the fixtures out of the test output directory, which the runtime probes on its own, and avoids + /// adding a fixture project or a compiler dependency to the test project. + /// + internal static class FixtureAssembly + { + /// + /// Emits at into , + /// signed with the same key as the product assemblies. + /// + /// The full path of the emitted assembly. + public static string Emit(string directory, string name, Version version) + { + Directory.CreateDirectory(directory); + + var assemblyName = new AssemblyName(name) + { + Version = version, + KeyPair = new StrongNameKeyPair(File.ReadAllBytes(Path.Combine(AppContext.BaseDirectory, "key.snk"))) + }; + + AssemblyBuilder builder = AppDomain.CurrentDomain.DefineDynamicAssembly( + assemblyName, + AssemblyBuilderAccess.RunAndSave, + directory); + + // A single type keeps the emitted assembly a valid, loadable image. + builder.DefineDynamicModule(name, name + ".dll") + .DefineType(name + ".Marker", TypeAttributes.Public) + .CreateType(); + + builder.Save(name + ".dll"); + + return Path.Combine(directory, name + ".dll"); + } + + /// Gets the public key token of an emitted assembly, formatted as a config file writes it. + public static string GetPublicKeyToken(string assemblyPath) => string.Concat( + AssemblyName.GetAssemblyName(assemblyPath).GetPublicKeyToken().Select(b => b.ToString("x2"))); + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/MSBuildExeConfigAssemblyResolveTests.cs b/src/MSBuildLocator.Tests/MSBuildExeConfigAssemblyResolveTests.cs new file mode 100644 index 0000000..18c0b20 --- /dev/null +++ b/src/MSBuildLocator.Tests/MSBuildExeConfigAssemblyResolveTests.cs @@ -0,0 +1,340 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.IO; +using System.Reflection; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.Locator.Tests +{ + /// + /// End-to-end coverage of the registered handler: a real + /// registration against a Visual Studio-shaped layout, followed by real assembly loads. Each test runs + /// in its own child AppDomain because registration is permanent within an AppDomain. + /// + [Collection(nameof(MSBuildAssemblyResolutionCollection))] + public class MSBuildExeConfigAssemblyResolveTests + { + private const string MSBuildExePathVariable = "MSBUILD_EXE_PATH"; + private static readonly Version FixtureVersion = new Version(9, 0, 0, 0); + + [Fact] + public void LoadsAnAssemblyFromACodeBaseOutsideTheRegisteredSearchPaths() => RunInChildAppDomain( + nameof(LoadsAnAssemblyFromACodeBaseOutsideTheRegisteredSearchPaths), + (install, runner) => + { + const string name = "SacFixtureShared"; + string fixture = FixtureAssembly.Emit(install.SharedAssemblies, name, FixtureVersion); + install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"{FakeVisualStudioInstall.Amd64ToRoot}\SharedAssemblies\{name}.dll")); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Theory] + [InlineData(@"MSBuild\Current\Bin", true)] + [InlineData(@"MSBuild\Current\Bin\amd64", true)] + [InlineData(@"MSBuild\Current\Bin", false)] + public void RelativeRegistrationResolvesCodeBasesAfterWorkingDirectoryChanges(string searchPath, bool useAmd64Config) => + RunInChildAppDomain(nameof(RelativeRegistrationResolvesCodeBasesAfterWorkingDirectoryChanges), (install, runner) => + { + const string name = "SacFixtureRelativePath"; + string fixture = FixtureAssembly.Emit(install.SharedAssemblies, name, FixtureVersion); + string configPath = useAmd64Config + ? install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"{FakeVisualStudioInstall.Amd64ToRoot}\SharedAssemblies\{name}.dll")) + : install.WriteBinConfig(CodeBaseEntry(name, fixture, $@"..\..\..\SharedAssemblies\{name}.dll")); + string unrelatedDirectory = Path.Combine(install.Root, "Unrelated"); + Directory.CreateDirectory(unrelatedDirectory); + string previousDirectory = Directory.GetCurrentDirectory(); + + try + { + Directory.SetCurrentDirectory(install.Root); + string selectedConfigPath = MSBuildExeConfigResolver.FindConfigFilePath(new[] { searchPath }); + runner.Register(new[] { searchPath }).ShouldBeNull(); + + Directory.SetCurrentDirectory(unrelatedDirectory); + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + selectedConfigPath.ShouldBe(configPath, StringCompareShould.IgnoreCase); + } + finally + { + Directory.SetCurrentDirectory(previousDirectory); + } + }); + + [Fact] + public void AppliesABindingRedirectAndReusesTheAssemblyItLoaded() => RunInChildAppDomain( + nameof(AppliesABindingRedirectAndReusesTheAssemblyItLoaded), + (install, runner) => + { + const string name = "SacFixtureRedirected"; + string fixture = FixtureAssembly.Emit(install.SharedAssemblies, name, FixtureVersion); + string token = FixtureAssembly.GetPublicKeyToken(fixture); + install.WriteAmd64Config($@" + + + + + "); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.LoadTwice( + FullName(name, fixture, new Version(1, 0, 0, 0)), + FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.FullName.ShouldBe($"{name}, Version=9.0.0.0, Culture=neutral, PublicKeyToken={token}"); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + + // Caching aliases means the pre-policy request and the effective identity share one assembly. + result.SameInstance.ShouldBeTrue(); + result.LoadedCount.ShouldBe(1); + }); + + [Fact] + public void PrefersAConfiguredCodeBaseOverAFileInARegisteredSearchPath() => RunInChildAppDomain( + nameof(PrefersAConfiguredCodeBaseOverAFileInARegisteredSearchPath), + (install, runner) => + { + const string name = "SacFixturePrecedence"; + string fixture = FixtureAssembly.Emit(install.SharedAssemblies, name, FixtureVersion); + string searchPathCopy = Path.Combine(install.Bin, name + ".dll"); + File.Copy(fixture, searchPathCopy); + install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"{FakeVisualStudioInstall.Amd64ToRoot}\SharedAssemblies\{name}.dll")); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + string.Equals(result.Location, searchPathCopy, StringComparison.OrdinalIgnoreCase).ShouldBeFalse(); + }); + + [Fact] + public void FallsBackToSearchPathProbingWhenTheCodeBaseTargetIsMissing() => RunInChildAppDomain( + nameof(FallsBackToSearchPathProbingWhenTheCodeBaseTargetIsMissing), + (install, runner) => + { + const string name = "SacFixtureMissingTarget"; + string fixture = FixtureAssembly.Emit(install.Bin, name, FixtureVersion); + install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"{FakeVisualStudioInstall.Amd64ToRoot}\SharedAssemblies\{name}.dll")); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Fact] + public void FallsBackToSearchPathProbingWhenTheCodeBaseTargetIsAnotherAssembly() => RunInChildAppDomain( + nameof(FallsBackToSearchPathProbingWhenTheCodeBaseTargetIsAnotherAssembly), + (install, runner) => + { + const string name = "SacFixtureIdentityChecked"; + string fixture = FixtureAssembly.Emit(install.Bin, name, FixtureVersion); + + // The configured code base points at an assembly with a different identity, which the handler + // must detect from the target's manifest instead of loading it for the requested name. + string imposter = FixtureAssembly.Emit(install.SharedAssemblies, "SacFixtureImposter", FixtureVersion); + File.Move(imposter, Path.Combine(install.SharedAssemblies, name + ".dll")); + + install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"{FakeVisualStudioInstall.Amd64ToRoot}\SharedAssemblies\{name}.dll")); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Fact] + public void FallsBackToSearchPathProbingWhenTheCodeBaseTargetIsNotAnAssembly() => RunInChildAppDomain( + nameof(FallsBackToSearchPathProbingWhenTheCodeBaseTargetIsNotAnAssembly), + (install, runner) => + { + const string name = "SacFixtureBadImage"; + string fixture = FixtureAssembly.Emit(install.Bin, name, FixtureVersion); + File.WriteAllText(Path.Combine(install.SharedAssemblies, name + ".dll"), "not a managed assembly"); + install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"{FakeVisualStudioInstall.Amd64ToRoot}\SharedAssemblies\{name}.dll")); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Fact] + public void UsesALaterCodeBaseWhenAnEarlierOneCannotBeLoaded() => RunInChildAppDomain( + nameof(UsesALaterCodeBaseWhenAnEarlierOneCannotBeLoaded), + (install, runner) => + { + const string name = "SacFixtureSecondCandidate"; + string fixture = FixtureAssembly.Emit(install.SharedAssemblies, name, FixtureVersion); + install.WriteAmd64Config($@" + + + + + "); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Fact] + public void MalformedConfigLeavesSearchPathProbingIntact() => RunInChildAppDomain( + nameof(MalformedConfigLeavesSearchPathProbingIntact), + (install, runner) => + { + const string name = "SacFixtureNoPolicy"; + string fixture = FixtureAssembly.Emit(install.Bin, name, FixtureVersion); + install.WriteRawAmd64Config("this is not a config file <<<"); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Fact] + public void CanonicalConfigAnchorsCodeBasesEvenWhenMSBuildExePathIsRewritten() => RunInChildAppDomain( + nameof(CanonicalConfigAnchorsCodeBasesEvenWhenMSBuildExePathIsRewritten), + (install, runner) => + { + // Registering the amd64 directory drives both the pre-17.1 MSBUILD_EXE_PATH rewrite, which points + // at the base bin directory, and code base resolution, which stays anchored at the config. + const string name = "SacFixtureAnchored"; + string fixture = FixtureAssembly.Emit(install.Bin, name, FixtureVersion); + install.WriteAmd64Config(CodeBaseEntry(name, fixture, $@"..\{name}.dll")); + + runner.Register(new[] { install.Amd64 }).ShouldBeNull(); + + runner.GetEnvironmentVariable(MSBuildExePathVariable) + .ShouldBe(Path.Combine(install.Bin, "MSBuild.exe"), StringCompareShould.IgnoreCase); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeTrue(result.Error); + + // The base bin directory was never registered, so only the amd64-anchored code base can reach it. + result.Location.ShouldBe(fixture, StringCompareShould.IgnoreCase); + }); + + [Fact] + public void AnAssemblyNoPolicyOrSearchPathProvidesStillFailsToLoad() => RunInChildAppDomain( + nameof(AnAssemblyNoPolicyOrSearchPathProvidesStillFailsToLoad), + (install, runner) => + { + install.WriteAmd64Config(@" + + + + "); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load( + "SacFixtureAbsent, Version=9.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"); + + result.Succeeded.ShouldBeFalse(); + result.Error.ShouldContain("SacFixtureAbsent"); + }); + + [Fact] + public void AnAssemblyOnlyACodeBaseCanReachDoesNotLoadWithoutOne() => RunInChildAppDomain( + nameof(AnAssemblyOnlyACodeBaseCanReachDoesNotLoadWithoutOne), + (install, runner) => + { + // The control for every code base test above: the shared assemblies directory is neither a + // registered search path nor a directory the runtime probes, so without a configured code base + // the identical request has to fail. + const string name = "SacFixtureUnreachable"; + string fixture = FixtureAssembly.Emit(install.SharedAssemblies, name, FixtureVersion); + install.WriteAmd64Config(CodeBaseEntry("SomethingElse", fixture, "SomethingElse.dll")); + + runner.Register(new[] { install.Bin }).ShouldBeNull(); + + AssemblyLoadResult result = runner.Load(FullName(name, fixture, FixtureVersion)); + + result.Succeeded.ShouldBeFalse(); + result.Error.ShouldContain(name); + }); + + private static string CodeBaseEntry(string name, string fixturePath, string href) => $@" + + + + "; + + private static string FullName(string name, string fixturePath, Version version) => + $"{name}, Version={version}, Culture=neutral, PublicKeyToken={FixtureAssembly.GetPublicKeyToken(fixturePath)}"; + + private static void RunInChildAppDomain(string name, Action test) => + TemporaryDirectory.Run(name, directory => + { + FakeVisualStudioInstall install = FakeVisualStudioInstall.Create(Path.Combine(directory, "VisualStudio")); + + // Registration sets MSBUILD_EXE_PATH, which is per-process rather than per-AppDomain. + string previousMSBuildExePath = Environment.GetEnvironmentVariable(MSBuildExePathVariable); + AppDomain domain = AppDomain.CreateDomain( + name, + securityInfo: null, + info: new AppDomainSetup { ApplicationBase = TestAssemblyDirectory }); + + try + { + var runner = (AssemblyResolutionRunner)domain.CreateInstanceAndUnwrap( + typeof(AssemblyResolutionRunner).Assembly.FullName, + typeof(AssemblyResolutionRunner).FullName); + + test(install, runner); + } + finally + { + AppDomain.Unload(domain); + Environment.SetEnvironmentVariable(MSBuildExePathVariable, previousMSBuildExePath); + } + }); + + /// + /// The directory the test assembly was built to, which is where the child AppDomain finds this + /// assembly and the product assembly. reports that directory even + /// when the test runner shadow copies. + /// + private static string TestAssemblyDirectory => Path.GetDirectoryName( + new Uri(typeof(MSBuildExeConfigAssemblyResolveTests).Assembly.CodeBase).LocalPath); + } + + // Child AppDomains share the process working directory and environment variables. + [CollectionDefinition(nameof(MSBuildAssemblyResolutionCollection), DisableParallelization = true)] + public class MSBuildAssemblyResolutionCollection + { + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/MSBuildExeConfigLayoutTests.cs b/src/MSBuildLocator.Tests/MSBuildExeConfigLayoutTests.cs new file mode 100644 index 0000000..f2b1761 --- /dev/null +++ b/src/MSBuildLocator.Tests/MSBuildExeConfigLayoutTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.Locator.Tests +{ + /// + /// Config selection and code base resolution against a directory layout shaped like a Visual Studio + /// installation, without requiring one to be installed. + /// + public class MSBuildExeConfigLayoutTests + { + private const string PublicKeyToken = "b03f5f7f11d50a3a"; + + [Fact] + public void CanonicalConfigResolvesCodeBasesBackToBinAndSharedAssemblies() => RunWithInstall(install => + { + string sharedAssembly = Path.Combine(install.SharedAssemblies, "Shared.dll"); + string binAssembly = Path.Combine(install.Bin, "Local.dll"); + File.WriteAllText(sharedAssembly, string.Empty); + File.WriteAllText(binAssembly, string.Empty); + + install.WriteAmd64Config($@" + + + + + + + + + "); + + AssemblyBindingPolicy policy = ReadCanonicalPolicy(install, out string configDirectory); + + // A relative href in the amd64 config reaches the installation-wide shared assemblies directory. + CodeBasePath(policy, configDirectory, "Shared", "1.0.0.0").ShouldBe(sharedAssembly); + File.Exists(sharedAssembly).ShouldBeTrue(); + + // And reaches back into the 32-bit bin directory the amd64 directory lives in. + CodeBasePath(policy, configDirectory, "Local", "9.0.0.0").ShouldBe(binAssembly); + File.Exists(binAssembly).ShouldBeTrue(); + }); + + [Fact] + public void RegisteringTheBinDirectoryUsesTheCanonicalAmd64Policy() => RunWithInstall(install => + { + install.WriteBinConfig(Entry("OnlyInBaseConfig")); + install.WriteAmd64Config(Entry("OnlyInAmd64Config")); + + AssemblyBindingPolicy policy = ReadCanonicalPolicy(install, out _); + + policy.DependentAssemblies.Select(entry => entry.Name).ShouldBe(new[] { "OnlyInAmd64Config" }); + }); + + [Fact] + public void RegisteringTheAmd64DirectoryUsesItsAdjacentPolicy() => RunWithInstall(install => + { + install.WriteBinConfig(Entry("OnlyInBaseConfig")); + install.WriteAmd64Config(Entry("OnlyInAmd64Config")); + + string configFilePath = MSBuildExeConfigResolver.FindConfigFilePath(new[] { install.Amd64 }); + + configFilePath.ShouldBe(Path.Combine(install.Amd64, "MSBuild.exe.config")); + MSBuildExeConfigReader.Read(configFilePath).DependentAssemblies.Select(entry => entry.Name) + .ShouldBe(new[] { "OnlyInAmd64Config" }); + }); + + [Fact] + public void AnOlderLayoutWithoutAnAmd64ConfigUsesTheBaseConfig() => RunWithInstall(install => + { + install.WriteBinConfig(Entry("OnlyInBaseConfig")); + + AssemblyBindingPolicy policy = ReadCanonicalPolicy(install, out string configDirectory); + + configDirectory.ShouldBe(install.Bin); + policy.DependentAssemblies.Select(entry => entry.Name).ShouldBe(new[] { "OnlyInBaseConfig" }); + }); + + [Fact] + public void AConfigThatCannotBeParsedLeavesNoPolicy() => RunWithInstall(install => + { + install.WriteBinConfig(Entry("OnlyInBaseConfig")); + install.WriteRawAmd64Config("truncated"); + + // The canonical config still wins; being unusable simply means no policy, not a base config fallback. + ReadCanonicalPolicy(install, out _).IsEmpty.ShouldBeTrue(); + }); + + private static string Entry(string name) => $@" + + + + "; + + private static AssemblyBindingPolicy ReadCanonicalPolicy(FakeVisualStudioInstall install, out string configDirectory) + { + string configFilePath = MSBuildExeConfigResolver.FindConfigFilePath(new[] { install.Bin }); + configDirectory = Path.GetDirectoryName(configFilePath); + return MSBuildExeConfigReader.Read(configFilePath); + } + + private static string CodeBasePath( + AssemblyBindingPolicy policy, + string configDirectory, + string name, + string version) => MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName($"{name}, Version={version}, Culture=neutral, PublicKeyToken={PublicKeyToken}"), + policy, + configDirectory).Single().Path; + + private static void RunWithInstall(Action test) => + TemporaryDirectory.Run(nameof(MSBuildExeConfigLayoutTests), directory => + test(FakeVisualStudioInstall.Create(Path.Combine(directory, "VisualStudio")))); + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/MSBuildExeConfigReaderTests.cs b/src/MSBuildLocator.Tests/MSBuildExeConfigReaderTests.cs new file mode 100644 index 0000000..2515418 --- /dev/null +++ b/src/MSBuildLocator.Tests/MSBuildExeConfigReaderTests.cs @@ -0,0 +1,538 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.Locator.Tests +{ + public class MSBuildExeConfigReaderTests + { + /// + /// A config shaped like the MSBuild.exe.config of a Visual Studio installation that relocates + /// dependencies out of the MSBuild bin directory. + /// + private const string SampleConfig = @" + + + + + + + + + + + + + + + + + + + +"; + + [Fact] + public void ReadXml_CapturesTheFullConfiguredIdentity() + { + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(SampleConfig).DependentAssemblies + .Single(d => d.Name == "System.Collections.Immutable"); + + entry.PublicKeyToken.ShouldBe("b03f5f7f11d50a3a"); + entry.Culture.ShouldBe("neutral"); + entry.ProcessorArchitecture.ShouldBe("msil"); + } + + [Fact] + public void ReadXml_KeepsEntryWithOnlyACodeBase() + { + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(SampleConfig).DependentAssemblies + .Single(d => d.Name == "Microsoft.DotNet.MSBuildSdkResolver"); + + entry.BindingRedirects.ShouldBeEmpty(); + entry.CodeBases.Single().Version.ShouldBe(new Version(8, 0, 0, 0)); + entry.CodeBases.Single().Href.ShouldBe(@"SdkResolvers\Microsoft.DotNet.MSBuildSdkResolver\Microsoft.DotNet.MSBuildSdkResolver.dll"); + } + + [Fact] + public void ReadXml_KeepsEntryWithOnlyABindingRedirect() + { + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(SampleConfig).DependentAssemblies + .Single(d => d.Name == "RedirectOnly"); + + entry.CodeBases.ShouldBeEmpty(); + entry.BindingRedirects.Single().NewVersion.ShouldBe(new Version(2, 0, 0, 0)); + } + + [Fact] + public void ReadXml_KeepsEveryRedirectAndCodeBaseOfAnEntryInConfigOrder() + { + const string xml = @" + + + + + + + + + +"; + + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies.Single(); + + entry.BindingRedirects.Select(r => r.NewVersion) + .ShouldBe(new[] { new Version(1, 0, 0, 0), new Version(2, 0, 0, 0) }); + entry.CodeBases.Select(c => c.Href).ShouldBe(new[] { @"one\Foo.dll", @"two\Foo.dll" }); + } + + [Fact] + public void ReadXml_NormalizesShortVersionsToFourComponents() + { + const string xml = @" + + + + + + + +"; + + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies.Single(); + AssemblyBindingRedirect redirect = entry.BindingRedirects.Single(); + + redirect.OldVersionLow.ShouldBe(new Version(0, 0, 0, 0)); + redirect.OldVersionHigh.ShouldBe(new Version(9, 0, 0, 0)); + redirect.NewVersion.ShouldBe(new Version(9, 0, 0, 0)); + entry.CodeBases.Single().Version.ShouldBe(new Version(9, 0, 0, 0)); + } + + [Fact] + public void ReadXml_SingleOldVersionBoundsTheRangeOnBothSides() + { + const string xml = @" + + + + + + +"; + + AssemblyBindingRedirect redirect = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies + .Single().BindingRedirects.Single(); + + redirect.OldVersionLow.ShouldBe(new Version(1, 2, 3, 4)); + redirect.OldVersionHigh.ShouldBe(new Version(1, 2, 3, 4)); + } + + [Fact] + public void ReadXml_InvalidRedirectDoesNotDiscardValidSiblings() + { + const string xml = @" + + + + + + + + + +"; + + AssemblyBindingRedirect redirect = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies + .Single().BindingRedirects.Single(); + + redirect.NewVersion.ShouldBe(new Version(3, 0, 0, 0)); + } + + [Fact] + public void ReadXml_InvalidCodeBaseDoesNotDiscardValidSiblings() + { + const string xml = @" + + + + + + + + +"; + + AssemblyCodeBase codeBase = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies + .Single().CodeBases.Single(); + + codeBase.Href.ShouldBe(@"good\Foo.dll"); + } + + [Fact] + public void ReadXml_CodeBaseWithoutVersionHasNoVersion() + { + const string xml = @" + + + + + + +"; + + AssemblyCodeBase codeBase = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies + .Single().CodeBases.Single(); + + codeBase.Version.ShouldBeNull(); + codeBase.Href.ShouldBe("Unsigned.dll"); + } + + [Fact] + public void ReadXml_KeepsDuplicateSimpleNamesInConfigOrder() + { + const string xml = @" + + + + + + + + + + + + + + +"; + + var entries = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies; + + entries.Count.ShouldBe(3); + entries.Select(e => e.ProcessorArchitecture).ShouldBe(new[] { "msil", "amd64", null }); + entries.Select(e => e.CodeBases.Single().Href) + .ShouldBe(new[] { @"msil\Foo.dll", @"amd64\Foo.dll", @"other\Foo.dll" }); + } + + [Fact] + public void ReadXml_SkipsEntriesThatCannotAffectResolution() + { + const string xml = @" + + + + + + + + + + + + + + + + + +"; + + MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies.Single().Name.ShouldBe("Kept"); + } + + [Fact] + public void ReadXml_CombinesMultipleAssemblyBindingSections() + { + const string xml = @" + + + + + + + + + + + + +"; + + MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies.Select(e => e.Name) + .ShouldBe(new[] { "First", "Second" }); + } + + [Fact] + public void ReadXml_IgnoresElementsOutsideTheAssemblyBindingNamespace() + { + const string xml = @" + + + + + + +"; + + MSBuildExeConfigReader.ReadXml(xml).IsEmpty.ShouldBeTrue(); + } + + [Fact] + public void ReadXml_ParsesQualifyAssembly() + { + AssemblyName qualified = MSBuildExeConfigReader.ReadXml(SampleConfig) + .QualifiedAssemblies["microsoft.dotnet.msbuildsdkresolver"]; + + qualified.Name.ShouldBe("Microsoft.DotNet.MSBuildSdkResolver"); + qualified.Version.ShouldBe(new Version(8, 0, 0, 0)); + qualified.CultureName.ShouldBe(string.Empty); + string.Concat(qualified.GetPublicKeyToken().Select(b => b.ToString("x2"))) + .ShouldBe("adb9793829ddae60"); + } + + [Fact] + public void ReadXml_SkipsUnusableQualifyAssemblyEntries() + { + const string xml = @" + + + + + + +"; + + var qualified = MSBuildExeConfigReader.ReadXml(xml).QualifiedAssemblies; + + qualified.Count.ShouldBe(1); + qualified["Good"].Version.ShouldBe(new Version(1, 0, 0, 0)); + } + + [Fact] + public void ReadXml_FirstQualifyAssemblyForAPartialNameWins() + { + const string xml = @" + + + + +"; + + MSBuildExeConfigReader.ReadXml(xml).QualifiedAssemblies["Foo"].Version + .ShouldBe(new Version(1, 0, 0, 0)); + } + + [Theory] + [InlineData("")] + [InlineData(@"")] + [InlineData("this is not xml <<<")] + [InlineData("")] + public void ReadXml_UnusableConfigIsEmpty(string xml) + { + AssemblyBindingPolicy policy = MSBuildExeConfigReader.ReadXml(xml); + + policy.IsEmpty.ShouldBeTrue(); + policy.DependentAssemblies.ShouldBeEmpty(); + policy.QualifiedAssemblies.ShouldBeEmpty(); + } + + [Fact] + public void ReadXml_DocumentTypeDefinitionIsRejected() + { + const string xml = @" +]> + + + + + + + +"; + + MSBuildExeConfigReader.ReadXml(xml).IsEmpty.ShouldBeTrue(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void Read_NoPathIsEmpty(string configFilePath) => + MSBuildExeConfigReader.Read(configFilePath).IsEmpty.ShouldBeTrue(); + + [Fact] + public void Read_MissingFileIsEmpty() => RunInTemporaryDirectory(directory => + MSBuildExeConfigReader.Read(Path.Combine(directory, "MSBuild.exe.config")).IsEmpty.ShouldBeTrue()); + + [Fact] + public void Read_InvalidPathIsEmpty() => + MSBuildExeConfigReader.Read("|not*a?path|").IsEmpty.ShouldBeTrue(); + + [Fact] + public void Read_DirectoryInsteadOfFileIsEmpty() => RunInTemporaryDirectory(directory => + MSBuildExeConfigReader.Read(directory).IsEmpty.ShouldBeTrue()); + + [Fact] + public void Read_ParsesConfigFile() => RunInTemporaryDirectory(directory => + { + string configFilePath = Path.Combine(directory, "MSBuild.exe.config"); + File.WriteAllText(configFilePath, SampleConfig); + + AssemblyBindingPolicy policy = MSBuildExeConfigReader.Read(configFilePath); + + policy.DependentAssemblies.Select(e => e.Name).ShouldBe( + new[] { "System.Collections.Immutable", "Microsoft.DotNet.MSBuildSdkResolver", "RedirectOnly" }); + policy.QualifiedAssemblies.Count.ShouldBe(1); + }); + + [Fact] + public void Read_MalformedConfigFileIsEmpty() => RunInTemporaryDirectory(directory => + { + string configFilePath = Path.Combine(directory, "MSBuild.exe.config"); + File.WriteAllText(configFilePath, ""); + + MSBuildExeConfigReader.Read(configFilePath).IsEmpty.ShouldBeTrue(); + }); + + [Theory] + [InlineData("1", 1, 0, 0, 0)] + [InlineData("1.2", 1, 2, 0, 0)] + [InlineData("1.2.3", 1, 2, 3, 0)] + [InlineData("1.2.3.4", 1, 2, 3, 4)] + [InlineData(" 1.2.3.4 ", 1, 2, 3, 4)] + [InlineData("65535.65535.65535.65535", 65535, 65535, 65535, 65535)] + public void TryParseAssemblyVersion_NormalizesValidVersions(string value, int major, int minor, int build, int revision) + { + MSBuildExeConfigReader.TryParseAssemblyVersion(value, out Version version).ShouldBeTrue(); + + version.ShouldBe(new Version(major, minor, build, revision)); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("1.2.3.4.5")] + [InlineData("1..2")] + [InlineData("1.2.")] + [InlineData("-1.0")] + [InlineData("1.0-preview")] + [InlineData("1.0.0.0x")] + [InlineData("65536.0.0.0")] + [InlineData("99999999999999999999")] + public void TryParseAssemblyVersion_RejectsInvalidVersions(string value) + { + MSBuildExeConfigReader.TryParseAssemblyVersion(value, out Version version).ShouldBeFalse(); + + version.ShouldBeNull(); + } + + [Fact] + public void GetEffectiveVersion_UsesTheFirstRedirectCoveringTheRequestedVersion() + { + const string xml = @" + + + + + + + +"; + + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(xml).DependentAssemblies.Single(); + + entry.GetEffectiveVersion(new Version(0, 5, 0, 0)).ShouldBe(new Version(1, 0, 0, 0)); + entry.GetEffectiveVersion(new Version(1, 0, 0, 0)).ShouldBe(new Version(1, 0, 0, 0)); + entry.GetEffectiveVersion(new Version(2, 0, 0, 0)).ShouldBe(new Version(9, 0, 0, 0)); + } + + [Fact] + public void GetEffectiveVersion_LeavesAnUncoveredVersionAlone() + { + DependentAssembly entry = MSBuildExeConfigReader.ReadXml(SampleConfig).DependentAssemblies + .Single(d => d.Name == "RedirectOnly"); + + entry.GetEffectiveVersion(new Version(3, 0, 0, 0)).ShouldBe(new Version(3, 0, 0, 0)); + entry.GetEffectiveVersion(null).ShouldBeNull(); + } + + [Fact] + public void TryGetLocalPath_ResolvesRelativeHrefAgainstTheConfigDirectory() + { + var codeBase = new AssemblyCodeBase(new Version(9, 0, 0, 0), @"..\..\..\..\SharedAssemblies\Foo.dll"); + + codeBase.TryGetLocalPath(@"C:\VS\MSBuild\Current\Bin\amd64", out string localPath).ShouldBeTrue(); + + localPath.ShouldBe(@"C:\VS\SharedAssemblies\Foo.dll"); + } + + [Fact] + public void TryGetLocalPath_AcceptsAbsolutePathsAndFileUris() + { + new AssemblyCodeBase(null, @"C:\VS\SharedAssemblies\Foo.dll") + .TryGetLocalPath(@"C:\VS\MSBuild\Current\Bin", out string absolutePath).ShouldBeTrue(); + absolutePath.ShouldBe(@"C:\VS\SharedAssemblies\Foo.dll"); + + new AssemblyCodeBase(null, new Uri(@"C:\VS\SharedAssemblies\Foo.dll").AbsoluteUri) + .TryGetLocalPath(@"C:\VS\MSBuild\Current\Bin", out string uriPath).ShouldBeTrue(); + uriPath.ShouldBe(@"C:\VS\SharedAssemblies\Foo.dll"); + } + + [Theory] + [InlineData("http://example.invalid/Foo.dll")] + [InlineData("https://example.invalid/Foo.dll")] + [InlineData("ftp://example.invalid/Foo.dll")] + public void TryGetLocalPath_RejectsNonFileUris(string href) + { + new AssemblyCodeBase(null, href).TryGetLocalPath(@"C:\VS\MSBuild\Current\Bin", out string localPath) + .ShouldBeFalse(); + + localPath.ShouldBeNull(); + } + + [Fact] + public void TryGetLocalPath_RejectsRelativeHrefWithoutADirectory() + { + new AssemblyCodeBase(null, @"sub\Foo.dll").TryGetLocalPath(null, out string localPath).ShouldBeFalse(); + + localPath.ShouldBeNull(); + } + + [Fact] + public void TryGetLocalPath_RejectsUnusableHref() + { + new AssemblyCodeBase(null, "|not*a?path|").TryGetLocalPath(@"C:\VS\MSBuild\Current\Bin", out string localPath) + .ShouldBeFalse(); + + localPath.ShouldBeNull(); + } + + private static void RunInTemporaryDirectory(Action test) + { + string directory = Path.Combine( + AppContext.BaseDirectory, + nameof(MSBuildExeConfigReaderTests) + "_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + test(directory); + } + finally + { + Directory.Delete(directory, recursive: true); + } + } + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/MSBuildExeConfigResolverTests.cs b/src/MSBuildLocator.Tests/MSBuildExeConfigResolverTests.cs new file mode 100644 index 0000000..1744e64 --- /dev/null +++ b/src/MSBuildLocator.Tests/MSBuildExeConfigResolverTests.cs @@ -0,0 +1,491 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Shouldly; +using Xunit; + +namespace Microsoft.Build.Locator.Tests +{ + public class MSBuildExeConfigResolverTests + { + private const string PublicKeyToken = "b03f5f7f11d50a3a"; + + [Fact] + public void FindConfigFilePath_UsesTheAdjacentConfigForAnExplicitAmd64Path() => RunInTemporaryDirectory(directory => + { + string amd64Directory = Path.Combine(directory, "amd64"); + Directory.CreateDirectory(amd64Directory); + File.WriteAllText(Path.Combine(amd64Directory, "MSBuild.exe"), string.Empty); + File.WriteAllText(Path.Combine(directory, "MSBuild.exe.config"), string.Empty); + + MSBuildExeConfigResolver.FindConfigFilePath(new[] { amd64Directory }) + .ShouldBe(Path.Combine(amd64Directory, "MSBuild.exe.config")); + }); + + [Fact] + public void FindConfigFilePath_PrefersAnExistingAmd64ConfigForABasePath() => RunInTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "MSBuild.exe"), string.Empty); + File.WriteAllText(Path.Combine(directory, "MSBuild.exe.config"), string.Empty); + string amd64Directory = Path.Combine(directory, "amd64"); + Directory.CreateDirectory(amd64Directory); + File.WriteAllText(Path.Combine(amd64Directory, "MSBuild.exe.config"), string.Empty); + + MSBuildExeConfigResolver.FindConfigFilePath(new[] { directory }) + .ShouldBe(Path.Combine(amd64Directory, "MSBuild.exe.config")); + }); + + [Fact] + public void FindConfigFilePath_FallsBackToTheAdjacentConfigOnlyWhenCanonicalIsAbsent() => RunInTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "MSBuild.exe"), string.Empty); + File.WriteAllText(Path.Combine(directory, "MSBuild.exe.config"), string.Empty); + + MSBuildExeConfigResolver.FindConfigFilePath(new[] { directory }) + .ShouldBe(Path.Combine(directory, "MSBuild.exe.config")); + }); + + [Fact] + public void GetCodeBaseCandidates_AppliesRedirectAndPreservesCodeBaseOrder() + { + AssemblyBindingPolicy policy = ReadPolicy(@" + + + + + +"); + + AssemblyCodeBaseCandidate[] candidates = MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName($"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"), + policy, + @"C:\Config").ToArray(); + + candidates.Select(c => c.Path).ShouldBe(new[] + { + @"C:\Config\first\Foo.dll", + @"C:\Config\second\Foo.dll", + }); + candidates.Select(c => c.EffectiveAssemblyName.Version).ShouldBe( + new[] { new Version(2, 0, 0, 0), new Version(2, 0, 0, 0) }); + } + + [Fact] + public void GetCodeBaseCandidates_QualifiesPartialRequestsForCodeBaseOnlyEntries() + { + const string fullName = "Resolver, Version=8.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a"; + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + +"); + + AssemblyCodeBaseCandidate candidate = MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName("Resolver"), + policy, + @"C:\Config").Single(); + + candidate.Path.ShouldBe(@"C:\Config\Resolver.dll"); + candidate.EffectiveAssemblyName.FullName.ShouldBe(fullName); + } + + [Fact] + public void GetCodeBaseCandidates_UsesVersionlessCodeBasesOnlyForUnsignedAssemblies() + { + AssemblyBindingPolicy policy = ReadPolicy(@" + + + +"); + + MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName("Unsigned, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"), + policy, + @"C:\Config").Single().Path.ShouldBe(@"C:\Config\Unsigned.dll"); + + MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName($"Unsigned, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"), + policy, + @"C:\Config").ShouldBeEmpty(); + } + + [Fact] + public void GetCodeBaseCandidates_DoesNotUseAQualifyAssemblyMappingToADifferentName() + { + AssemblyBindingPolicy policy = ReadPolicy(@" + + + + +"); + + MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName("Original"), + policy, + @"C:\Config").ShouldBeEmpty(); + } + + [Fact] + public void HasCompatibleIdentity_RequiresConfiguredIdentityAndEffectiveVersion() + { + AssemblyBindingPolicy policy = ReadPolicy(@" + + + + +"); + + AssemblyCodeBaseCandidate candidate = MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName($"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"), + policy, + @"C:\Config").Single(); + + candidate.HasCompatibleIdentity(new AssemblyName( + $"Foo, Version=2.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}, processorArchitecture=MSIL")) + .ShouldBeTrue(); + candidate.HasCompatibleIdentity(new AssemblyName( + $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}, processorArchitecture=MSIL")) + .ShouldBeFalse(); + candidate.HasCompatibleIdentity(new AssemblyName( + $"Bar, Version=2.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}, processorArchitecture=MSIL")) + .ShouldBeFalse(); + } + + [Fact] + public void GetCodeBaseCandidates_DistinguishesEntriesThatShareASimpleName() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + + + + + + + + +"); + + // AssemblyResolve usually reports no architecture, which cannot rule out either architecture-qualified + // entry, so both remain candidates in config order and the manifest check decides. + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}") + .ShouldBe(new[] { @"C:\Config\msil\Foo.dll", @"C:\Config\amd64\Foo.dll" }); + + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}, processorArchitecture=amd64") + .ShouldBe(new[] { @"C:\Config\amd64\Foo.dll" }); + + CodeBasePaths(policy, "Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35") + .ShouldBe(new[] { @"C:\Config\other\Foo.dll" }); + } + + [Fact] + public void GetCodeBaseCandidates_RequiresTheConfiguredPublicKeyToken() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + +"); + + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}") + .ShouldBe(new[] { @"C:\Config\Foo.dll" }); + CodeBasePaths(policy, "Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35").ShouldBeEmpty(); + CodeBasePaths(policy, "Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null").ShouldBeEmpty(); + } + + [Fact] + public void GetCodeBaseCandidates_TreatsAnUnspecifiedOrNullTokenAsUnsigned() + { + AssemblyBindingPolicy policy = ReadPolicy(@" + + + +"); + + CodeBasePaths(policy, "Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null") + .ShouldBe(new[] { @"C:\Config\Foo.dll" }); + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}").ShouldBeEmpty(); + } + + [Fact] + public void GetCodeBaseCandidates_RequiresTheConfiguredCulture() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + + + + +"); + + CodeBasePaths(policy, $"Resources, Version=1.0.0.0, Culture=de, PublicKeyToken={PublicKeyToken}") + .ShouldBe(new[] { @"C:\Config\de\Resources.dll" }); + CodeBasePaths(policy, $"Resources, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}").ShouldBeEmpty(); + CodeBasePaths(policy, $"Neutral, Version=1.0.0.0, Culture=de, PublicKeyToken={PublicKeyToken}").ShouldBeEmpty(); + } + + [Fact] + public void GetCodeBaseCandidates_SelectsTheCodeBaseForTheEffectiveVersion() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + + + + +"); + + CodeBasePaths(policy, Requested("1.0.0.0")).ShouldBe(new[] { @"C:\Config\two\Foo.dll" }); + CodeBasePaths(policy, Requested("2.3.0.0")).ShouldBe(new[] { @"C:\Config\three\Foo.dll" }); + + // A version no redirect covers keeps its own version, which is how a standalone code base is reached. + CodeBasePaths(policy, Requested("4.0.0.0")).ShouldBe(new[] { @"C:\Config\four\Foo.dll" }); + CodeBasePaths(policy, Requested("5.0.0.0")).ShouldBeEmpty(); + + string Requested(string version) => + $"Foo, Version={version}, Culture=neutral, PublicKeyToken={PublicKeyToken}"; + } + + [Fact] + public void GetCodeBaseCandidates_ComparesShortConfiguredVersionsAsFourComponents() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + +"); + + AssemblyCodeBaseCandidate candidate = SingleCandidate( + policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"); + + candidate.Path.ShouldBe(@"C:\Config\Foo.dll"); + candidate.EffectiveAssemblyName.Version.ShouldBe(new Version(9, 0, 0, 0)); + } + + [Fact] + public void GetCodeBaseCandidates_ProducesNothingForARedirectWithoutACodeBase() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + +"); + + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}").ShouldBeEmpty(); + } + + [Fact] + public void GetCodeBaseCandidates_AcceptsAbsolutePathsAndFileUris() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + + +"); + + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}").ShouldBe(new[] + { + @"C:\Absolute\Foo.dll", + @"C:\Uri\Foo.dll", + @"C:\Config\normalized\Foo.dll", + }); + } + + [Fact] + public void GetCodeBaseCandidates_SkipsCodeBasesThatAreNotLocalFiles() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + + +"); + + CodeBasePaths(policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}") + .ShouldBe(new[] { @"C:\Config\local\Foo.dll" }); + } + + [Fact] + public void GetCodeBaseCandidates_SkipsRelativeCodeBasesWithoutAConfigDirectory() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + +"); + + MSBuildExeConfigResolver.GetCodeBaseCandidates( + new AssemblyName($"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"), + policy, + configDirectory: null).ShouldBeEmpty(); + } + + [Theory] + [InlineData("Resolver")] + [InlineData("Resolver, Culture=neutral")] + [InlineData("Resolver, Culture=neutral, PublicKeyToken=null")] + public void GetCodeBaseCandidates_QualifiesPartialRequestsInEveryForm(string partialName) + { + AssemblyCodeBaseCandidate candidate = SingleCandidate(QualifiedResolverPolicy, partialName); + + candidate.Path.ShouldBe(@"C:\Config\Resolver.dll"); + candidate.EffectiveAssemblyName.Version.ShouldBe(new Version(8, 0, 0, 0)); + } + + [Fact] + public void GetCodeBaseCandidates_DoesNotQualifyARequestThatAlreadyStatesAnIdentity() + { + // Like the runtime, a qualifyAssembly mapping only completes a partial reference. A request that + // already carries a version binds on its own, and here its unsigned identity cannot match. + CodeBasePaths(QualifiedResolverPolicy, "Resolver, Version=8.0.0.0").ShouldBeEmpty(); + } + + [Fact] + public void GetCodeBaseCandidates_ReturnsNothingWithoutAUsableRequestOrPolicy() + { + var assemblyName = new AssemblyName($"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"); + + MSBuildExeConfigResolver.GetCodeBaseCandidates(null, AssemblyBindingPolicy.Empty, @"C:\Config").ShouldBeEmpty(); + MSBuildExeConfigResolver.GetCodeBaseCandidates(assemblyName, null, @"C:\Config").ShouldBeEmpty(); + MSBuildExeConfigResolver.GetCodeBaseCandidates(assemblyName, AssemblyBindingPolicy.Empty, @"C:\Config").ShouldBeEmpty(); + } + + [Fact] + public void HasCompatibleIdentity_RejectsAMismatchedTokenOrCulture() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + +"); + + AssemblyCodeBaseCandidate candidate = SingleCandidate( + policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"); + + candidate.HasCompatibleIdentity(new AssemblyName($"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}")) + .ShouldBeTrue(); + candidate.HasCompatibleIdentity(new AssemblyName("Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35")) + .ShouldBeFalse(); + candidate.HasCompatibleIdentity(new AssemblyName("Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null")) + .ShouldBeFalse(); + candidate.HasCompatibleIdentity(new AssemblyName($"Foo, Version=1.0.0.0, Culture=de, PublicKeyToken={PublicKeyToken}")) + .ShouldBeFalse(); + candidate.HasCompatibleIdentity(null).ShouldBeFalse(); + } + + [Fact] + public void HasCompatibleIdentity_RequiresTheConfiguredProcessorArchitecture() + { + AssemblyBindingPolicy policy = ReadPolicy($@" + + + +"); + + AssemblyCodeBaseCandidate candidate = SingleCandidate( + policy, $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}"); + + candidate.HasCompatibleIdentity(new AssemblyName( + $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}, processorArchitecture=amd64")) + .ShouldBeTrue(); + candidate.HasCompatibleIdentity(new AssemblyName( + $"Foo, Version=1.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}, processorArchitecture=MSIL")) + .ShouldBeFalse(); + } + + [Fact] + public void HasCompatibleIdentity_AcceptsAnyVersionWhenTheRequestDidNotStateOne() + { + AssemblyBindingPolicy policy = ReadPolicy(@" + + + +"); + + AssemblyCodeBaseCandidate candidate = SingleCandidate(policy, "Unsigned"); + + candidate.EffectiveAssemblyName.Version.ShouldBeNull(); + candidate.HasCompatibleIdentity(new AssemblyName("Unsigned, Version=3.0.0.0, Culture=neutral, PublicKeyToken=null")) + .ShouldBeTrue(); + candidate.HasCompatibleIdentity(new AssemblyName($"Unsigned, Version=3.0.0.0, Culture=neutral, PublicKeyToken={PublicKeyToken}")) + .ShouldBeFalse(); + } + + [Fact] + public void FindConfigFilePath_SkipsSearchPathsWithoutMSBuildExe() => RunInTemporaryDirectory(directory => + { + string nugetDirectory = Path.Combine(directory, "NuGet"); + string binDirectory = Path.Combine(directory, "Bin"); + Directory.CreateDirectory(nugetDirectory); + Directory.CreateDirectory(binDirectory); + File.WriteAllText(Path.Combine(binDirectory, "MSBuild.exe"), string.Empty); + File.WriteAllText(Path.Combine(binDirectory, "MSBuild.exe.config"), string.Empty); + + MSBuildExeConfigResolver.FindConfigFilePath(new[] { nugetDirectory, binDirectory }) + .ShouldBe(Path.Combine(binDirectory, "MSBuild.exe.config")); + }); + + [Fact] + public void FindConfigFilePath_IsNullWhenNoSearchPathContainsMSBuildExe() => RunInTemporaryDirectory(directory => + MSBuildExeConfigResolver.FindConfigFilePath(new[] { directory }).ShouldBeNull()); + + [Fact] + public void FindConfigFilePath_NamesTheAdjacentConfigEvenWhenItDoesNotExist() => RunInTemporaryDirectory(directory => + { + File.WriteAllText(Path.Combine(directory, "MSBuild.exe"), string.Empty); + + string configFilePath = MSBuildExeConfigResolver.FindConfigFilePath(new[] { directory }); + + configFilePath.ShouldBe(Path.Combine(directory, "MSBuild.exe.config")); + + // A deployment without a config declares no policy, which leaves ordinary probing in charge. + MSBuildExeConfigReader.Read(configFilePath).IsEmpty.ShouldBeTrue(); + }); + + /// + /// A code base only reachable by qualifying a partial request, as the amd64 config of a Visual Studio + /// installation declares for the .NET SDK resolver. + /// + private static AssemblyBindingPolicy QualifiedResolverPolicy => ReadPolicy($@" + + + + +"); + + private static string[] CodeBasePaths(AssemblyBindingPolicy policy, string assemblyName) => + MSBuildExeConfigResolver.GetCodeBaseCandidates(new AssemblyName(assemblyName), policy, @"C:\Config") + .Select(candidate => candidate.Path).ToArray(); + + private static AssemblyCodeBaseCandidate SingleCandidate(AssemblyBindingPolicy policy, string assemblyName) => + MSBuildExeConfigResolver.GetCodeBaseCandidates(new AssemblyName(assemblyName), policy, @"C:\Config").Single(); + + private static AssemblyBindingPolicy ReadPolicy(string bindingContent) => + MSBuildExeConfigReader.ReadXml($@" + +{bindingContent} + +"); + + private static void RunInTemporaryDirectory(Action test) => + TemporaryDirectory.Run(nameof(MSBuildExeConfigResolverTests), test); + } +} + +#endif diff --git a/src/MSBuildLocator.Tests/Microsoft.Build.Locator.Tests.csproj b/src/MSBuildLocator.Tests/Microsoft.Build.Locator.Tests.csproj index ecf1684..cbb2e34 100644 --- a/src/MSBuildLocator.Tests/Microsoft.Build.Locator.Tests.csproj +++ b/src/MSBuildLocator.Tests/Microsoft.Build.Locator.Tests.csproj @@ -14,4 +14,8 @@ + + + + diff --git a/src/MSBuildLocator.Tests/TemporaryDirectory.cs b/src/MSBuildLocator.Tests/TemporaryDirectory.cs new file mode 100644 index 0000000..2c55b22 --- /dev/null +++ b/src/MSBuildLocator.Tests/TemporaryDirectory.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if !NETCOREAPP + +using System; +using System.IO; +using System.Threading; + +namespace Microsoft.Build.Locator.Tests +{ + /// + /// Runs a test against a directory that exists only for the duration of that test. + /// + internal static class TemporaryDirectory + { + public static void Run(string name, Action test) + { + string directory = Path.Combine(AppContext.BaseDirectory, name + "_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(directory); + + try + { + test(directory); + } + finally + { + Delete(directory); + } + } + + private static void Delete(string directory) + { + // An assembly loaded from the directory keeps a file handle open until the AppDomain that loaded it + // has finished unloading, so a delete that loses that race must not fail a test that already passed. + for (int attempt = 0; ; attempt++) + { + try + { + Directory.Delete(directory, recursive: true); + return; + } + catch (Exception e) when (e is IOException || e is UnauthorizedAccessException) + { + if (attempt == 4) + { + return; + } + + Thread.Sleep(100); + } + } + } + } +} + +#endif diff --git a/src/MSBuildLocator/AssemblyBindingPolicy.cs b/src/MSBuildLocator/AssemblyBindingPolicy.cs new file mode 100644 index 0000000..e308cb0 --- /dev/null +++ b/src/MSBuildLocator/AssemblyBindingPolicy.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET46 + +using System; +using System.Collections.Generic; +using System.Reflection; + +namespace Microsoft.Build.Locator +{ + /// + /// The assembly binding information declared by a .NET Framework application configuration file such + /// as MSBuild.exe.config. .NET Framework only: application configuration files have no equivalent on + /// .NET (Core). + /// + internal class AssemblyBindingPolicy + { + /// A policy that declares nothing, used when no usable config file is available. + public static readonly AssemblyBindingPolicy Empty = new AssemblyBindingPolicy( + Array.Empty(), + new Dictionary(0, StringComparer.OrdinalIgnoreCase)); + + public AssemblyBindingPolicy( + IReadOnlyList dependentAssemblies, + IReadOnlyDictionary qualifiedAssemblies) + { + DependentAssemblies = dependentAssemblies; + QualifiedAssemblies = qualifiedAssemblies; + } + + /// + /// Every usable dependentAssembly entry, in config order. Entries can share a simple name + /// while differing by public key token, culture, or processor architecture, so they are kept as an + /// ordered list rather than keyed by name. + /// + public IReadOnlyList DependentAssemblies { get; } + + /// + /// Maps the partialName of every usable qualifyAssembly entry to the full assembly name + /// it names. Lookup is case-insensitive, matching assembly name comparison. + /// + public IReadOnlyDictionary QualifiedAssemblies { get; } + + /// Gets a value indicating whether the policy has nothing to contribute to assembly resolution. + public bool IsEmpty => DependentAssemblies.Count == 0 && QualifiedAssemblies.Count == 0; + } +} + +#endif diff --git a/src/MSBuildLocator/AssemblyBindingRedirect.cs b/src/MSBuildLocator/AssemblyBindingRedirect.cs new file mode 100644 index 0000000..37daa9a --- /dev/null +++ b/src/MSBuildLocator/AssemblyBindingRedirect.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET46 + +using System; + +namespace Microsoft.Build.Locator +{ + /// + /// A single bindingRedirect element of a dependentAssembly entry in a .NET Framework + /// application configuration file. + /// + internal class AssemblyBindingRedirect + { + public AssemblyBindingRedirect(Version oldVersionLow, Version oldVersionHigh, Version newVersion) + { + OldVersionLow = oldVersionLow; + OldVersionHigh = oldVersionHigh; + NewVersion = newVersion; + } + + /// Inclusive low bound of the oldVersion range. + public Version OldVersionLow { get; } + + /// Inclusive high bound of the oldVersion range. + public Version OldVersionHigh { get; } + + /// The version the oldVersion range binds to. + public Version NewVersion { get; } + + /// + /// Gets a value indicating whether falls inside the inclusive + /// oldVersion range. + /// + public bool Includes(Version version) => + version != null && version >= OldVersionLow && version <= OldVersionHigh; + } +} + +#endif diff --git a/src/MSBuildLocator/AssemblyCodeBase.cs b/src/MSBuildLocator/AssemblyCodeBase.cs new file mode 100644 index 0000000..0dcc472 --- /dev/null +++ b/src/MSBuildLocator/AssemblyCodeBase.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET46 + +using System; +using System.IO; +using System.Security; + +namespace Microsoft.Build.Locator +{ + /// + /// A single codeBase element of a dependentAssembly entry in a .NET Framework + /// application configuration file. + /// + internal class AssemblyCodeBase + { + public AssemblyCodeBase(Version version, string href) + { + Version = version; + Href = href; + } + + /// + /// The assembly version this location provides, or when the config does not + /// specify one. The runtime ignores the version for assemblies without a strong name. + /// + public Version Version { get; } + + /// The location as written in the config: a relative path, an absolute path, or a URI. + public string Href { get; } + + /// + /// Converts to a normalized local path, resolving a relative href against + /// , which is the directory containing the config file. + /// + /// + /// when the href is not a local path or file:// URI, or when it cannot + /// be normalized. Existence of the file is not checked. + /// + public bool TryGetLocalPath(string baseDirectory, out string localPath) + { + localPath = null; + + try + { + if (Uri.TryCreate(Href, UriKind.Absolute, out Uri uri)) + { + // Honor absolute local paths and file:// URIs; ignore http(s) and any other scheme. + if (!uri.IsFile) + { + return false; + } + + localPath = Path.GetFullPath(uri.LocalPath); + return true; + } + + if (string.IsNullOrEmpty(baseDirectory)) + { + return false; + } + + localPath = Path.GetFullPath(Path.Combine(baseDirectory, Href)); + return true; + } + catch (Exception e) when (e is ArgumentException || e is NotSupportedException || e is IOException || e is SecurityException) + { + localPath = null; + return false; + } + } + } +} + +#endif diff --git a/src/MSBuildLocator/DependentAssembly.cs b/src/MSBuildLocator/DependentAssembly.cs new file mode 100644 index 0000000..eac61af --- /dev/null +++ b/src/MSBuildLocator/DependentAssembly.cs @@ -0,0 +1,77 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET46 + +using System; +using System.Collections.Generic; + +namespace Microsoft.Build.Locator +{ + /// + /// A single dependentAssembly entry of a .NET Framework application configuration file: an + /// assembly identity plus the binding redirects and code bases declared for it. The two kinds of + /// child element are independent, so an entry can declare only code bases or only redirects. + /// + internal class DependentAssembly + { + public DependentAssembly( + string name, + string publicKeyToken, + string culture, + string processorArchitecture, + IReadOnlyList bindingRedirects, + IReadOnlyList codeBases) + { + Name = name; + PublicKeyToken = publicKeyToken; + Culture = culture; + ProcessorArchitecture = processorArchitecture; + BindingRedirects = bindingRedirects; + CodeBases = codeBases; + } + + /// Simple assembly name from assemblyIdentity. + public string Name { get; } + + /// Public key token from assemblyIdentity, or when unspecified. + public string PublicKeyToken { get; } + + /// Culture from assemblyIdentity, or when unspecified. + public string Culture { get; } + + /// Processor architecture from assemblyIdentity, or when unspecified. + public string ProcessorArchitecture { get; } + + /// Every usable bindingRedirect of the entry, in config order. + public IReadOnlyList BindingRedirects { get; } + + /// Every usable codeBase of the entry, in config order. + public IReadOnlyList CodeBases { get; } + + /// + /// Applies the binding redirects of this entry to , returning the + /// version the runtime would bind to. Like the runtime, the first redirect covering the requested + /// version wins, and a version no redirect covers is returned unchanged. + /// + public Version GetEffectiveVersion(Version requestedVersion) + { + if (requestedVersion == null) + { + return null; + } + + foreach (AssemblyBindingRedirect redirect in BindingRedirects) + { + if (redirect.Includes(requestedVersion)) + { + return redirect.NewVersion; + } + } + + return requestedVersion; + } + } +} + +#endif diff --git a/src/MSBuildLocator/MSBuildExeConfigReader.cs b/src/MSBuildLocator/MSBuildExeConfigReader.cs new file mode 100644 index 0000000..3009444 --- /dev/null +++ b/src/MSBuildLocator/MSBuildExeConfigReader.cs @@ -0,0 +1,318 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET46 + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Reflection; +using System.Security; +using System.Xml; + +namespace Microsoft.Build.Locator +{ + /// + /// Reads the assembly binding information of a .NET Framework application configuration file such as + /// MSBuild.exe.config. Applications that use MSBuild through this library do not run as MSBuild.exe, so + /// the runtime never applies that config; reading it lets assembly resolution honor the locations the + /// selected MSBuild deployment declares for its dependencies. + /// + internal static class MSBuildExeConfigReader + { + private const string AssemblyBindingNamespacePrefix = "asm"; + private const string AssemblyBindingNamespace = "urn:schemas-microsoft-com:asm.v1"; + private const string DependentAssemblyPath = "/*/runtime/asm:assemblyBinding/asm:dependentAssembly"; + private const string QualifyAssemblyPath = "/*/runtime/asm:assemblyBinding/asm:qualifyAssembly"; + + /// + /// Reads the policy declared by the config file at . A missing, + /// unreadable, or malformed config yields . + /// + public static AssemblyBindingPolicy Read(string configFilePath) + { + if (string.IsNullOrEmpty(configFilePath)) + { + return AssemblyBindingPolicy.Empty; + } + + try + { + if (!File.Exists(configFilePath)) + { + return AssemblyBindingPolicy.Empty; + } + + using (XmlReader reader = XmlReader.Create(configFilePath, CreateReaderSettings())) + { + return Parse(reader); + } + } + catch (Exception e) when (IsUnusableConfigException(e)) + { + return AssemblyBindingPolicy.Empty; + } + } + + /// + /// Reads the policy declared by config XML. Malformed XML yields . + /// + public static AssemblyBindingPolicy ReadXml(string xml) + { + try + { + using (var input = new StringReader(xml ?? string.Empty)) + using (XmlReader reader = XmlReader.Create(input, CreateReaderSettings())) + { + return Parse(reader); + } + } + catch (Exception e) when (IsUnusableConfigException(e)) + { + return AssemblyBindingPolicy.Empty; + } + } + + /// + /// Parses an assembly version of one to four components, normalizing it to four components so that + /// comparison against a fully specified assembly version is meaningful. + /// + public static bool TryParseAssemblyVersion(string value, out Version version) + { + version = null; + + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + string[] parts = value.Trim().Split('.'); + if (parts.Length > 4) + { + return false; + } + + var components = new int[4]; + for (int i = 0; i < parts.Length; i++) + { + // NumberStyles.None rejects signs, whitespace, and separators, so only plain digits parse. + if (!int.TryParse(parts[i], NumberStyles.None, CultureInfo.InvariantCulture, out int component) || + component > ushort.MaxValue) + { + return false; + } + + components[i] = component; + } + + version = new Version(components[0], components[1], components[2], components[3]); + return true; + } + + private static XmlReaderSettings CreateReaderSettings() + { + // A config file never legitimately needs a DTD or any other external resource, and resolving one + // would let a malformed or hostile config reach outside the file. + return new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }; + } + + private static AssemblyBindingPolicy Parse(XmlReader reader) + { + var document = new XmlDocument { XmlResolver = null }; + document.Load(reader); + + var namespaces = new XmlNamespaceManager(document.NameTable); + namespaces.AddNamespace(AssemblyBindingNamespacePrefix, AssemblyBindingNamespace); + + IReadOnlyList dependentAssemblies = ParseDependentAssemblies(document, namespaces); + IReadOnlyDictionary qualifiedAssemblies = ParseQualifiedAssemblies(document, namespaces); + + return dependentAssemblies.Count == 0 && qualifiedAssemblies.Count == 0 + ? AssemblyBindingPolicy.Empty + : new AssemblyBindingPolicy(dependentAssemblies, qualifiedAssemblies); + } + + private static IReadOnlyList ParseDependentAssemblies( + XmlDocument document, + XmlNamespaceManager namespaces) + { + var dependentAssemblies = new List(); + + foreach (XmlNode node in SelectNodes(document, DependentAssemblyPath, namespaces)) + { + XmlNode identity = node.SelectSingleNode("asm:assemblyIdentity", namespaces); + string name = GetAttribute(identity, "name"); + if (string.IsNullOrEmpty(name)) + { + continue; + } + + IReadOnlyList redirects = ParseBindingRedirects(node, namespaces); + IReadOnlyList codeBases = ParseCodeBases(node, namespaces); + + // An entry that declares neither cannot affect where an assembly loads from. + if (redirects.Count == 0 && codeBases.Count == 0) + { + continue; + } + + dependentAssemblies.Add(new DependentAssembly( + name, + GetAttribute(identity, "publicKeyToken"), + GetAttribute(identity, "culture"), + GetAttribute(identity, "processorArchitecture"), + redirects, + codeBases)); + } + + return dependentAssemblies; + } + + private static IReadOnlyList ParseBindingRedirects( + XmlNode dependentAssembly, + XmlNamespaceManager namespaces) + { + var redirects = new List(); + + foreach (XmlNode node in SelectNodes(dependentAssembly, "asm:bindingRedirect", namespaces)) + { + if (!TryParseOldVersionRange(GetAttribute(node, "oldVersion"), out Version low, out Version high) || + !TryParseAssemblyVersion(GetAttribute(node, "newVersion"), out Version newVersion)) + { + continue; + } + + redirects.Add(new AssemblyBindingRedirect(low, high, newVersion)); + } + + return redirects; + } + + private static IReadOnlyList ParseCodeBases( + XmlNode dependentAssembly, + XmlNamespaceManager namespaces) + { + var codeBases = new List(); + + foreach (XmlNode node in SelectNodes(dependentAssembly, "asm:codeBase", namespaces)) + { + string href = GetAttribute(node, "href"); + if (string.IsNullOrEmpty(href)) + { + continue; + } + + // The version is optional in that the runtime ignores it for assemblies without a strong name, + // but one that is present and unparsable makes the entry unusable. + string versionAttribute = GetAttribute(node, "version"); + Version version = null; + if (!string.IsNullOrEmpty(versionAttribute) && !TryParseAssemblyVersion(versionAttribute, out version)) + { + continue; + } + + codeBases.Add(new AssemblyCodeBase(version, href)); + } + + return codeBases; + } + + private static IReadOnlyDictionary ParseQualifiedAssemblies( + XmlDocument document, + XmlNamespaceManager namespaces) + { + var qualifiedAssemblies = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (XmlNode node in SelectNodes(document, QualifyAssemblyPath, namespaces)) + { + string partialName = GetAttribute(node, "partialName"); + string fullName = GetAttribute(node, "fullName"); + if (string.IsNullOrEmpty(partialName) || + string.IsNullOrEmpty(fullName) || + qualifiedAssemblies.ContainsKey(partialName)) + { + continue; + } + + AssemblyName qualifiedName; + try + { + qualifiedName = new AssemblyName(fullName); + } + catch (Exception e) when (e is ArgumentException || e is FileLoadException) + { + continue; + } + + qualifiedAssemblies.Add(partialName, qualifiedName); + } + + return qualifiedAssemblies; + } + + private static bool TryParseOldVersionRange(string oldVersion, out Version low, out Version high) + { + low = null; + high = null; + + if (string.IsNullOrEmpty(oldVersion)) + { + return false; + } + + int separator = oldVersion.IndexOf('-'); + if (separator < 0) + { + if (!TryParseAssemblyVersion(oldVersion, out low)) + { + return false; + } + + high = low; + return true; + } + + if (!TryParseAssemblyVersion(oldVersion.Substring(0, separator), out low) || + !TryParseAssemblyVersion(oldVersion.Substring(separator + 1), out high) || + low > high) + { + low = null; + high = null; + return false; + } + + return true; + } + + private static IEnumerable SelectNodes(XmlNode node, string xpath, XmlNamespaceManager namespaces) + { + XmlNodeList nodes = node.SelectNodes(xpath, namespaces); + if (nodes == null) + { + yield break; + } + + foreach (XmlNode selected in nodes) + { + yield return selected; + } + } + + private static string GetAttribute(XmlNode node, string name) => node?.Attributes?[name]?.Value; + + private static bool IsUnusableConfigException(Exception e) => + e is XmlException || + e is IOException || + e is UnauthorizedAccessException || + e is SecurityException || + e is NotSupportedException || + e is ArgumentException; + } +} + +#endif diff --git a/src/MSBuildLocator/MSBuildExeConfigResolver.cs b/src/MSBuildLocator/MSBuildExeConfigResolver.cs new file mode 100644 index 0000000..cb091bc --- /dev/null +++ b/src/MSBuildLocator/MSBuildExeConfigResolver.cs @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET46 + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; + +namespace Microsoft.Build.Locator +{ + /// + /// Selects an MSBuild executable config and applies the parts of its assembly-binding policy that + /// identify code base candidates. + /// + internal static class MSBuildExeConfigResolver + { + /// + /// Finds the config to use for the first registered path containing MSBuild.exe. An explicitly + /// registered amd64 directory uses its adjacent config; other directories prefer an existing amd64 + /// config, then use the config adjacent to the executable. + /// + public static string FindConfigFilePath(IEnumerable msbuildSearchPaths) + { + foreach (string msbuildPath in msbuildSearchPaths) + { + string msbuildExePath = Path.Combine(msbuildPath, "MSBuild.exe"); + if (!File.Exists(msbuildExePath)) + { + continue; + } + + msbuildExePath = Path.GetFullPath(msbuildExePath); + string executableDirectory = Path.GetDirectoryName(msbuildExePath); + if (string.Equals(Path.GetFileName(executableDirectory), "amd64", StringComparison.OrdinalIgnoreCase)) + { + return msbuildExePath + ".config"; + } + + string amd64ConfigPath = Path.Combine(executableDirectory, "amd64", "MSBuild.exe.config"); + return File.Exists(amd64ConfigPath) ? amd64ConfigPath : msbuildExePath + ".config"; + } + + return null; + } + + /// + /// Gets every local code base candidate applicable to , in + /// config order. File existence and metadata validation are intentionally left to the caller so a + /// failed candidate can fall through to the next one. + /// + public static IEnumerable GetCodeBaseCandidates( + AssemblyName requestedAssembly, + AssemblyBindingPolicy policy, + string configDirectory) + { + if (requestedAssembly == null || string.IsNullOrEmpty(requestedAssembly.Name) || policy == null) + { + yield break; + } + + AssemblyName qualifiedAssembly = QualifyAssembly(requestedAssembly, policy); + + foreach (DependentAssembly dependentAssembly in policy.DependentAssemblies) + { + if (!IdentityMatches(dependentAssembly, qualifiedAssembly)) + { + continue; + } + + Version effectiveVersion = dependentAssembly.GetEffectiveVersion(qualifiedAssembly.Version); + bool isUnsigned = !HasPublicKeyToken(qualifiedAssembly); + + foreach (AssemblyCodeBase codeBase in dependentAssembly.CodeBases) + { + if (!CodeBaseMatches(codeBase, effectiveVersion, isUnsigned) || + !codeBase.TryGetLocalPath(configDirectory, out string path)) + { + continue; + } + + yield return new AssemblyCodeBaseCandidate( + path, + CloneWithVersion(qualifiedAssembly, effectiveVersion), + dependentAssembly); + } + } + } + + private static AssemblyName QualifyAssembly(AssemblyName requestedAssembly, AssemblyBindingPolicy policy) + { + if (IsPartialAssemblyName(requestedAssembly) && + policy.QualifiedAssemblies.TryGetValue(requestedAssembly.Name, out AssemblyName qualifiedAssembly) && + string.Equals(requestedAssembly.Name, qualifiedAssembly.Name, StringComparison.OrdinalIgnoreCase)) + { + return qualifiedAssembly; + } + + return requestedAssembly; + } + + private static bool IsPartialAssemblyName(AssemblyName assemblyName) => + assemblyName.Version == null && + !HasPublicKeyToken(assemblyName) && + IsNeutralCulture(assemblyName.CultureName) && + assemblyName.ProcessorArchitecture == ProcessorArchitecture.None; + + private static bool IdentityMatches(DependentAssembly dependentAssembly, AssemblyName requestedAssembly) => + string.Equals(dependentAssembly.Name, requestedAssembly.Name, StringComparison.OrdinalIgnoreCase) && + PublicKeyTokenMatches(dependentAssembly.PublicKeyToken, requestedAssembly) && + CultureMatches(dependentAssembly.Culture, requestedAssembly) && + ProcessorArchitectureMatches(dependentAssembly.ProcessorArchitecture, requestedAssembly); + + private static bool CodeBaseMatches(AssemblyCodeBase codeBase, Version effectiveVersion, bool isUnsigned) + { + if (codeBase.Version == null) + { + return isUnsigned; + } + + return effectiveVersion != null && codeBase.Version.Equals(effectiveVersion); + } + + private static bool PublicKeyTokenMatches(string configuredToken, AssemblyName assemblyName) + { + byte[] publicKeyToken = assemblyName.GetPublicKeyToken(); + bool isUnsigned = publicKeyToken == null || publicKeyToken.Length == 0; + + if (string.IsNullOrEmpty(configuredToken) || + string.Equals(configuredToken, "null", StringComparison.OrdinalIgnoreCase)) + { + return isUnsigned; + } + + return !isUnsigned && + string.Equals(configuredToken, GetPublicKeyTokenString(publicKeyToken), StringComparison.OrdinalIgnoreCase); + } + + private static bool CultureMatches(string configuredCulture, AssemblyName assemblyName) + { + if (string.IsNullOrEmpty(configuredCulture) || + string.Equals(configuredCulture, "neutral", StringComparison.OrdinalIgnoreCase)) + { + return IsNeutralCulture(assemblyName.CultureName); + } + + return string.Equals(configuredCulture, assemblyName.CultureName, StringComparison.OrdinalIgnoreCase); + } + + private static bool ProcessorArchitectureMatches(string configuredArchitecture, AssemblyName assemblyName) + { + // AssemblyResolve commonly reports no architecture, even for an MSIL assembly. In that case the + // configured architecture is still checked against the loaded target's manifest below. + return string.IsNullOrEmpty(configuredArchitecture) || + assemblyName.ProcessorArchitecture == ProcessorArchitecture.None || + string.Equals(configuredArchitecture, assemblyName.ProcessorArchitecture.ToString(), StringComparison.OrdinalIgnoreCase); + } + + internal static bool IsNeutralCulture(string culture) => + string.IsNullOrEmpty(culture) || string.Equals(culture, "neutral", StringComparison.OrdinalIgnoreCase); + + private static bool HasPublicKeyToken(AssemblyName assemblyName) + { + byte[] publicKeyToken = assemblyName.GetPublicKeyToken(); + return publicKeyToken != null && publicKeyToken.Length != 0; + } + + internal static string GetPublicKeyTokenString(byte[] publicKeyToken) + { + var token = new char[publicKeyToken.Length * 2]; + const string HexDigits = "0123456789abcdef"; + for (int i = 0; i < publicKeyToken.Length; i++) + { + token[i * 2] = HexDigits[publicKeyToken[i] >> 4]; + token[(i * 2) + 1] = HexDigits[publicKeyToken[i] & 0x0f]; + } + + return new string(token); + } + + private static AssemblyName CloneWithVersion(AssemblyName assemblyName, Version version) + { + var clone = (AssemblyName)assemblyName.Clone(); + clone.Version = version; + return clone; + } + } + + /// + /// A local code base candidate together with the identity its target must have before it is loaded. + /// + internal class AssemblyCodeBaseCandidate + { + public AssemblyCodeBaseCandidate(string path, AssemblyName effectiveAssemblyName, DependentAssembly dependentAssembly) + { + Path = path; + EffectiveAssemblyName = effectiveAssemblyName; + DependentAssembly = dependentAssembly; + } + + /// The normalized local path from the config's codeBase href. + public string Path { get; } + + /// The qualified request after applying any binding redirect. + public AssemblyName EffectiveAssemblyName { get; } + + /// The dependentAssembly entry that produced this candidate. + public DependentAssembly DependentAssembly { get; } + + /// + /// Validates that a code base target has the identity this candidate declares, preventing a stale or + /// malicious config from loading an unrelated assembly. + /// + public bool HasCompatibleIdentity(AssemblyName targetAssembly) => + targetAssembly != null && + string.Equals(EffectiveAssemblyName.Name, targetAssembly.Name, StringComparison.OrdinalIgnoreCase) && + VersionMatches(targetAssembly) && + PublicKeyTokensMatch(targetAssembly) && + CulturesMatch(targetAssembly) && + ProcessorArchitecturesMatch(targetAssembly); + + private bool VersionMatches(AssemblyName targetAssembly) => + EffectiveAssemblyName.Version == null || EffectiveAssemblyName.Version.Equals(targetAssembly.Version); + + private bool PublicKeyTokensMatch(AssemblyName targetAssembly) + { + byte[] effectiveToken = EffectiveAssemblyName.GetPublicKeyToken(); + byte[] targetToken = targetAssembly.GetPublicKeyToken(); + bool effectiveIsUnsigned = effectiveToken == null || effectiveToken.Length == 0; + bool targetIsUnsigned = targetToken == null || targetToken.Length == 0; + + return effectiveIsUnsigned == targetIsUnsigned && + (effectiveIsUnsigned || + string.Equals( + MSBuildExeConfigResolver.GetPublicKeyTokenString(effectiveToken), + MSBuildExeConfigResolver.GetPublicKeyTokenString(targetToken), + StringComparison.OrdinalIgnoreCase)); + } + + private bool CulturesMatch(AssemblyName targetAssembly) + { + string effectiveCulture = EffectiveAssemblyName.CultureName; + string targetCulture = targetAssembly.CultureName; + return MSBuildExeConfigResolver.IsNeutralCulture(effectiveCulture) + ? MSBuildExeConfigResolver.IsNeutralCulture(targetCulture) + : string.Equals(effectiveCulture, targetCulture, StringComparison.OrdinalIgnoreCase); + } + + private bool ProcessorArchitecturesMatch(AssemblyName targetAssembly) + { + string configuredArchitecture = DependentAssembly.ProcessorArchitecture; + if (!string.IsNullOrEmpty(configuredArchitecture)) + { + return string.Equals( + configuredArchitecture, + targetAssembly.ProcessorArchitecture.ToString(), + StringComparison.OrdinalIgnoreCase); + } + + return EffectiveAssemblyName.ProcessorArchitecture == ProcessorArchitecture.None || + EffectiveAssemblyName.ProcessorArchitecture == targetAssembly.ProcessorArchitecture; + } + } +} + +#endif diff --git a/src/MSBuildLocator/MSBuildLocator.cs b/src/MSBuildLocator/MSBuildLocator.cs index 9d76676..34deabb 100644 --- a/src/MSBuildLocator/MSBuildLocator.cs +++ b/src/MSBuildLocator/MSBuildLocator.cs @@ -244,6 +244,10 @@ private static void RegisterMSBuildPathsInternally(string[] msbuildSearchPaths) var loadedAssemblies = new Dictionary(); #if NET46 + string msbuildExeConfigPath = MSBuildExeConfigResolver.FindConfigFilePath(msbuildSearchPaths); + AssemblyBindingPolicy bindingPolicy = MSBuildExeConfigReader.Read(msbuildExeConfigPath); + string bindingPolicyDirectory = msbuildExeConfigPath == null ? null : Path.GetDirectoryName(msbuildExeConfigPath); + // MSBuild can be loaded from the x86 or x64 folder. Before 17.0, it looked next to the executing assembly in some cases and constructed a path that assumed x86 in others. // This overrides the latter assumption to let it find the right MSBuild. foreach (string path in msbuildSearchPaths) @@ -289,11 +293,56 @@ Assembly TryLoadAssembly(AssemblyName assemblyName) // Assembly resolution is not thread-safe. lock (loadedAssemblies) { - if (loadedAssemblies.TryGetValue(assemblyName.FullName, out Assembly assembly)) + if (!string.IsNullOrEmpty(assemblyName.FullName) && + loadedAssemblies.TryGetValue(assemblyName.FullName, out Assembly assembly)) { return assembly; } +#if NET46 + foreach (AssemblyCodeBaseCandidate candidate in MSBuildExeConfigResolver.GetCodeBaseCandidates( + assemblyName, + bindingPolicy, + bindingPolicyDirectory)) + { + if (loadedAssemblies.TryGetValue(candidate.EffectiveAssemblyName.FullName, out assembly)) + { + CacheAssembly(assemblyName, candidate.EffectiveAssemblyName, assembly); + return assembly; + } + + if (!File.Exists(candidate.Path)) + { + continue; + } + + try + { + AssemblyName targetAssemblyName = AssemblyName.GetAssemblyName(candidate.Path); + if (!candidate.HasCompatibleIdentity(targetAssemblyName)) + { + continue; + } + + assembly = Assembly.LoadFrom(candidate.Path); + CacheAssembly(assemblyName, candidate.EffectiveAssemblyName, assembly); + return assembly; + } + catch (Exception e) when ( + e is IOException || + e is UnauthorizedAccessException || + e is System.Security.SecurityException || + e is ArgumentException || + e is NotSupportedException || + e is BadImageFormatException || + e is FileLoadException) + { + // A missing, invalid, or inaccessible code base must not prevent later candidates + // or the legacy MSBuild-directory probing from resolving the request. + } + } +#endif + // Look in the MSBuild folder for any unresolved reference. It may be a dependency // of MSBuild or a task. foreach (string msbuildPath in msbuildSearchPaths) @@ -302,7 +351,7 @@ Assembly TryLoadAssembly(AssemblyName assemblyName) if (File.Exists(targetAssembly)) { assembly = Assembly.LoadFrom(targetAssembly); - loadedAssemblies.Add(assemblyName.FullName, assembly); + CacheAssembly(assemblyName, null, assembly); return assembly; } } @@ -310,6 +359,21 @@ Assembly TryLoadAssembly(AssemblyName assemblyName) return null; } } + + void CacheAssembly(AssemblyName originalRequest, AssemblyName effectiveAssemblyName, Assembly assembly) + { + CacheAssemblyName(originalRequest?.FullName, assembly); + CacheAssemblyName(effectiveAssemblyName?.FullName, assembly); + CacheAssemblyName(assembly.GetName().FullName, assembly); + } + + void CacheAssemblyName(string assemblyName, Assembly assembly) + { + if (!string.IsNullOrEmpty(assemblyName) && !loadedAssemblies.ContainsKey(assemblyName)) + { + loadedAssemblies.Add(assemblyName, assembly); + } + } } /// diff --git a/src/MSBuildLocator/README.md b/src/MSBuildLocator/README.md index 65bca12..11deab8 100644 --- a/src/MSBuildLocator/README.md +++ b/src/MSBuildLocator/README.md @@ -23,6 +23,10 @@ MSBuildLocator.RegisterDefaults(); // the MSBuild assemblies are used in your application. // The safest way to ensure this is to put any MSBuild API // access into a separate method. +// NOTE: on .NET Framework, registration honors the selected MSBuild +// deployment's executable config and codeBase policy. Because +// app-local assemblies load before AssemblyResolve can fire, +// they still take precedence. LoadProject(); void LoadProject() @@ -54,4 +58,3 @@ For complete documentation, see [Use Microsoft.Build.Locator](https://learn.micr See the [BuilderApp](https://github.com/microsoft/MSBuildLocator/blob/a349ee7ffd889cd7634d3fd8b413bf9f29244b50/samples/BuilderApp) sample for a full exploration of the MSBuildLocator library and capabilities. - diff --git a/version.json b/version.json index 571bb27..333106a 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.11", + "version": "1.12", "assemblyVersion": "1.0.0.0", "publicReleaseRefSpec": [ "^refs/heads/release/.*"