From 56ead8118a9a5c72816a594652b0c3c8023d216b Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Mon, 5 Feb 2018 18:03:35 -0800 Subject: [PATCH] Decouple Razor tools from MVC Adds a loader (with shadow copying in server mode) based on the Roslyn Analyzer loader design. Adds some targets to the Razor SDK that we can use to compute the configuration and extensions. Passes all of the metadata through to the command line tools so they can deal with extensions. --- .../ExtensionInitializer.cs | 15 ++ .../Properties/AssemblyInfo.cs | 5 + .../RazorExtensions.cs | 2 + .../Microsoft.AspNetCore.Mvc.Razor.props | 5 +- ...etCore.Razor.Design.CodeGeneration.targets | 6 + .../AssemblyExtension.cs | 31 +++ .../EmptyProjectFileSystem.cs | 23 ++ ...ovideRazorExtensionInitializerAttribute.cs | 31 +++ .../RazorExtensionInitializer.cs | 10 + .../RazorProjectEngine.cs | 50 +++- .../RazorProjectFileSystem.cs | 2 + .../RazorGenerate.cs | 37 +++ .../RazorTagHelper.cs | 28 ++ .../Application.cs | 8 +- .../CompilerHost.cs | 46 ++-- .../DefaultExtensionAssemblyLoader.cs | 241 ++++++++++++++++++ .../DefaultExtensionDependencyChecker.cs | 155 +++++++++++ .../DiscoverCommand.cs | 73 +++++- .../ExtensionAssemblyLoader.cs | 16 ++ .../ExtensionDependencyChecker.cs | 12 + .../GenerateCommand.cs | 83 ++++-- .../MetadataReaderExtensions.cs | 93 +++++++ .../Microsoft.AspNetCore.Razor.Tools.csproj | 2 +- .../Program.cs | 6 +- .../ShadowCopyManager.cs | 169 ++++++++++++ .../BuildServerTestFixture.cs | 3 +- .../Language/TestRazorProjectFileSystem.cs | 2 +- .../DefaultExtensionAssemblyLoaderTest.cs | 128 ++++++++++ .../DefaultExtensionDependencyCheckerTest.cs | 111 ++++++++ .../Infrastructure/ServerUtilities.cs | 3 +- .../LoaderTestResources.cs | 146 +++++++++++ ...crosoft.AspNetCore.Razor.Tools.Test.csproj | 6 + .../Properties/AssemblyInfo.cs | 6 + .../TempDirectory.cs | 30 +++ .../TestDefaultExtensionAssemblyLoader.cs | 25 ++ .../AppWithP2PReference.csproj | 5 + .../testapps/ClassLibrary/ClassLibrary.csproj | 5 + test/testapps/SimpleMvc/SimpleMvc.csproj | 5 + test/testapps/SimplePages/SimplePages.csproj | 5 + 39 files changed, 1569 insertions(+), 60 deletions(-) create mode 100644 src/Microsoft.AspNetCore.Mvc.Razor.Extensions/ExtensionInitializer.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Language/AssemblyExtension.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Language/EmptyProjectFileSystem.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Language/ProvideRazorExtensionInitializerAttribute.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Language/RazorExtensionInitializer.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionAssemblyLoader.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionDependencyChecker.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Tools/ExtensionAssemblyLoader.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Tools/ExtensionDependencyChecker.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Tools/ShadowCopyManager.cs create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionAssemblyLoaderTest.cs create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionDependencyCheckerTest.cs create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/LoaderTestResources.cs create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/Properties/AssemblyInfo.cs create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/TempDirectory.cs create mode 100644 test/Microsoft.AspNetCore.Razor.Tools.Test/TestDefaultExtensionAssemblyLoader.cs diff --git a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/ExtensionInitializer.cs b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/ExtensionInitializer.cs new file mode 100644 index 0000000000..eeb3246f74 --- /dev/null +++ b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/ExtensionInitializer.cs @@ -0,0 +1,15 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.AspNetCore.Mvc.Razor.Extensions +{ + internal class ExtensionInitializer : RazorExtensionInitializer + { + public override void Initialize(RazorProjectEngineBuilder builder) + { + RazorExtensions.Register(builder); + } + } +} diff --git a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/Properties/AssemblyInfo.cs index 6be3a1f170..b5caca17dd 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/Properties/AssemblyInfo.cs @@ -2,6 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Runtime.CompilerServices; +using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; + +[assembly: ProvideRazorExtensionInitializer("MVC-2.0", typeof(ExtensionInitializer))] +[assembly: ProvideRazorExtensionInitializer("MVC-2.1", typeof(ExtensionInitializer))] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Mvc.Razor.Extensions.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.VisualStudio.Editor.Razor, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] diff --git a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/RazorExtensions.cs b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/RazorExtensions.cs index c22878bd09..abf1b447f0 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/RazorExtensions.cs +++ b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/RazorExtensions.cs @@ -25,6 +25,8 @@ namespace Microsoft.AspNetCore.Mvc.Razor.Extensions InheritsDirective.Register(builder); SectionDirective.Register(builder); + builder.Features.Add(new ViewComponentTagHelperDescriptorProvider()); + builder.AddTargetExtension(new ViewComponentTagHelperTargetExtension()); builder.AddTargetExtension(new TemplateTargetExtension() { diff --git a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Razor.props b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Razor.props index 8d2ac3b630..ef8766cdf2 100644 --- a/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Razor.props +++ b/src/Microsoft.AspNetCore.Mvc.Razor.Extensions/build/netstandard2.0/Microsoft.AspNetCore.Mvc.Razor.props @@ -10,6 +10,9 @@ Set the primary configuration supported by this pacakge as the default configuration for Razor. --> MVC-2.1 + + + <_MvcExtensionAssemblyPath Condition="'$(_MvcExtensionAssemblyPath)'==''">$(MSBuildThisFileDirectory)..\..\lib\netstandard2.0\Microsoft.AspNetCore.Mvc.Razor.Extensions.dll @@ -26,7 +29,7 @@ Microsoft.AspNetCore.Mvc.Razor.Extensions - $(MSBuildThisFileDirectory)..\..\lib\netstandard2.0\Microsoft.AspNetCore.Mvc.Razor.Extensions.dll + $(_MvcExtensionAssemblyPath) \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Design/build/netstandard2.0/Microsoft.AspNetCore.Razor.Design.CodeGeneration.targets b/src/Microsoft.AspNetCore.Razor.Design/build/netstandard2.0/Microsoft.AspNetCore.Razor.Design.CodeGeneration.targets index 699b4608f3..8215f37eaf 100644 --- a/src/Microsoft.AspNetCore.Razor.Design/build/netstandard2.0/Microsoft.AspNetCore.Razor.Design.CodeGeneration.targets +++ b/src/Microsoft.AspNetCore.Razor.Design/build/netstandard2.0/Microsoft.AspNetCore.Razor.Design.CodeGeneration.targets @@ -73,6 +73,9 @@ UseServer="$(UseRazorBuildServer)" ForceServer="$(_RazorForceBuildServer)" PipeName="$(_RazorBuildServerPipeName)" + Version="$(RazorLangVersion)" + Configuration="@(ResolvedRazorConfiguration)" + Extensions="@(ResolvedRazorExtension)" Assemblies="@(RazorReferencePath)" ProjectRoot="$(MSBuildProjectDirectory)" TagHelperManifest="$(_RazorTagHelperOutputCache)"> @@ -121,6 +124,9 @@ UseServer="$(UseRazorBuildServer)" ForceServer="$(_RazorForceBuildServer)" PipeName="$(_RazorBuildServerPipeName)" + Version="$(RazorLangVersion)" + Configuration="@(ResolvedRazorConfiguration)" + Extensions="@(ResolvedRazorExtension)" Sources="@(RazorGenerateWithTargetPath)" ProjectRoot="$(MSBuildProjectDirectory)" TagHelperManifest="$(_RazorTagHelperOutputCache)" /> diff --git a/src/Microsoft.AspNetCore.Razor.Language/AssemblyExtension.cs b/src/Microsoft.AspNetCore.Razor.Language/AssemblyExtension.cs new file mode 100644 index 0000000000..b93323e018 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Language/AssemblyExtension.cs @@ -0,0 +1,31 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Reflection; + +namespace Microsoft.AspNetCore.Razor.Language +{ + internal class AssemblyExtension : RazorExtension + { + public AssemblyExtension(string extensionName, Assembly assembly) + { + if (extensionName == null) + { + throw new ArgumentNullException(nameof(extensionName)); + } + + if (assembly == null) + { + throw new ArgumentNullException(nameof(assembly)); + } + + ExtensionName = extensionName; + Assembly = assembly; + } + + public override string ExtensionName { get; } + + public Assembly Assembly { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Language/EmptyProjectFileSystem.cs b/src/Microsoft.AspNetCore.Razor.Language/EmptyProjectFileSystem.cs new file mode 100644 index 0000000000..61ced1271b --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Language/EmptyProjectFileSystem.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Linq; + +namespace Microsoft.AspNetCore.Razor.Language +{ + internal class EmptyProjectFileSystem : RazorProjectFileSystem + { + public override IEnumerable EnumerateItems(string basePath) + { + NormalizeAndEnsureValidPath(basePath); + return Enumerable.Empty(); + } + + public override RazorProjectItem GetItem(string path) + { + NormalizeAndEnsureValidPath(path); + return new NotFoundProjectItem(string.Empty, path); + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Language/ProvideRazorExtensionInitializerAttribute.cs b/src/Microsoft.AspNetCore.Razor.Language/ProvideRazorExtensionInitializerAttribute.cs new file mode 100644 index 0000000000..374419a676 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Language/ProvideRazorExtensionInitializerAttribute.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Razor.Language +{ + [AttributeUsage(AttributeTargets.Assembly, AllowMultiple = true, Inherited = false)] + public class ProvideRazorExtensionInitializerAttribute : Attribute + { + public ProvideRazorExtensionInitializerAttribute(string extensionName, Type initializerType) + { + if (extensionName == null) + { + throw new ArgumentNullException(nameof(extensionName)); + } + + if (initializerType == null) + { + throw new ArgumentNullException(nameof(initializerType)); + } + + ExtensionName = extensionName; + InitializerType = initializerType; + } + + public string ExtensionName { get; } + + public Type InitializerType { get; } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Language/RazorExtensionInitializer.cs b/src/Microsoft.AspNetCore.Razor.Language/RazorExtensionInitializer.cs new file mode 100644 index 0000000000..5117115af6 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Language/RazorExtensionInitializer.cs @@ -0,0 +1,10 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNetCore.Razor.Language +{ + public abstract class RazorExtensionInitializer + { + public abstract void Initialize(RazorProjectEngineBuilder builder); + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Language/RazorProjectEngine.cs b/src/Microsoft.AspNetCore.Razor.Language/RazorProjectEngine.cs index 09ae61dc1f..e76bdffc94 100644 --- a/src/Microsoft.AspNetCore.Razor.Language/RazorProjectEngine.cs +++ b/src/Microsoft.AspNetCore.Razor.Language/RazorProjectEngine.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Reflection; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language.Extensions; @@ -71,9 +72,17 @@ namespace Microsoft.AspNetCore.Razor.Language var builder = new DefaultRazorProjectEngineBuilder(configuration, fileSystem); + // The intialization order is somewhat important. + // + // Defaults -> Extensions -> Additional customization + // + // This allows extensions to rely on default features, and customizations to override choices made by + // extensions. RazorEngine.AddDefaultPhases(builder.Phases); AddDefaultsFeatures(builder.Features); + LoadExtensions(builder, configuration.Extensions); + configure?.Invoke(builder); return builder.Build(); @@ -142,19 +151,38 @@ namespace Microsoft.AspNetCore.Razor.Language }); } - internal static void AddDefaultRuntimeFeatures(RazorConfiguration configuration, ICollection features) + private static void LoadExtensions(RazorProjectEngineBuilder builder, IReadOnlyList extensions) { - // Configure options - features.Add(new DefaultRazorParserOptionsFeature(designTime: false, version: configuration.LanguageVersion)); - features.Add(new DefaultRazorCodeGenerationOptionsFeature(designTime: false)); - } + for (var i = 0; i < extensions.Count; i++) + { + // For now we only handle AssemblyExtension - which is not user-constructable. We're keeping a tight + // lid on how things work until we add official support for extensibility everywhere. So, this is + // intentionally inflexible for the time being. + var extension = extensions[i] as AssemblyExtension; + if (extension == null) + { + continue; + } - internal static void AddDefaultDesignTimeFeatures(RazorConfiguration configuration, ICollection features) - { - // Configure options - features.Add(new DefaultRazorParserOptionsFeature(designTime: true, version: configuration.LanguageVersion)); - features.Add(new DefaultRazorCodeGenerationOptionsFeature(designTime: true)); - features.Add(new SuppressChecksumOptionsFeature()); + // It's not an error to have an assembly with no initializers. This is useful to specify a dependency + // that doesn't really provide any Razor configuration. + var attributes = extension.Assembly.GetCustomAttributes(); + foreach (var attribute in attributes) + { + // Using extension names and requiring them to line up allows a single assembly to ship multiple + // extensions/initializers for different configurations. + if (!string.Equals(attribute.ExtensionName, extension.ExtensionName, StringComparison.Ordinal)) + { + continue; + } + + // There's no real protection/exception handling here because this set isn't really user-extensible + // right now. This would be a great place to add some additional diagnostics and hardening in the + // future. + var initializer = (RazorExtensionInitializer)Activator.CreateInstance(attribute.InitializerType); + initializer.Initialize(builder); + } + } } } } diff --git a/src/Microsoft.AspNetCore.Razor.Language/RazorProjectFileSystem.cs b/src/Microsoft.AspNetCore.Razor.Language/RazorProjectFileSystem.cs index a95de255df..799c6bda65 100644 --- a/src/Microsoft.AspNetCore.Razor.Language/RazorProjectFileSystem.cs +++ b/src/Microsoft.AspNetCore.Razor.Language/RazorProjectFileSystem.cs @@ -7,6 +7,8 @@ namespace Microsoft.AspNetCore.Razor.Language { public abstract class RazorProjectFileSystem : RazorProject { + internal static readonly RazorProjectFileSystem Empty = new EmptyProjectFileSystem(); + /// /// Create a Razor project file system based off of a root directory. /// diff --git a/src/Microsoft.AspNetCore.Razor.Tasks/RazorGenerate.cs b/src/Microsoft.AspNetCore.Razor.Tasks/RazorGenerate.cs index 7389725f94..03cef74692 100644 --- a/src/Microsoft.AspNetCore.Razor.Tasks/RazorGenerate.cs +++ b/src/Microsoft.AspNetCore.Razor.Tasks/RazorGenerate.cs @@ -12,6 +12,18 @@ namespace Microsoft.AspNetCore.Razor.Tasks private const string GeneratedOutput = "GeneratedOutput"; private const string TargetPath = "TargetPath"; private const string FullPath = "FullPath"; + private const string Identity = "Identity"; + private const string AssemblyName = "AssemblyName"; + private const string AssemblyFilePath = "AssemblyFilePath"; + + [Required] + public string Version { get; set; } + + [Required] + public ITaskItem[] Configuration { get; set; } + + [Required] + public ITaskItem[] Extensions { get; set; } [Required] public ITaskItem[] Sources { get; set; } @@ -36,6 +48,16 @@ namespace Microsoft.AspNetCore.Razor.Tasks } } + for (var i = 0; i < Extensions.Length; i++) + { + if (!EnsureRequiredMetadata(Extensions[i], Identity) || + !EnsureRequiredMetadata(Extensions[i], AssemblyName) || + !EnsureRequiredMetadata(Extensions[i], AssemblyFilePath)) + { + return false; + } + } + return base.ValidateParameters(); } @@ -65,6 +87,21 @@ namespace Microsoft.AspNetCore.Razor.Tasks builder.AppendLine("-t"); builder.AppendLine(TagHelperManifest); + builder.AppendLine("-v"); + builder.AppendLine(Version); + + builder.AppendLine("-c"); + builder.AppendLine(Configuration[0].GetMetadata(Identity)); + + for (var i = 0; i < Extensions.Length; i++) + { + builder.AppendLine("-n"); + builder.AppendLine(Extensions[i].GetMetadata(Identity)); + + builder.AppendLine("-e"); + builder.AppendLine(Path.GetFullPath(Extensions[i].GetMetadata(AssemblyFilePath))); + } + return builder.ToString(); } diff --git a/src/Microsoft.AspNetCore.Razor.Tasks/RazorTagHelper.cs b/src/Microsoft.AspNetCore.Razor.Tasks/RazorTagHelper.cs index 4064163c34..99d523a2b7 100644 --- a/src/Microsoft.AspNetCore.Razor.Tasks/RazorTagHelper.cs +++ b/src/Microsoft.AspNetCore.Razor.Tasks/RazorTagHelper.cs @@ -10,6 +10,19 @@ namespace Microsoft.AspNetCore.Razor.Tasks { public class RazorTagHelper : DotNetToolTask { + private const string Identity = "Identity"; + private const string AssemblyName = "AssemblyName"; + private const string AssemblyFilePath = "AssemblyFilePath"; + + [Required] + public string Version { get; set; } + + [Required] + public ITaskItem[] Configuration { get; set; } + + [Required] + public ITaskItem[] Extensions { get; set; } + [Required] public string[] Assemblies { get; set; } @@ -51,6 +64,21 @@ namespace Microsoft.AspNetCore.Razor.Tasks builder.AppendLine("-p"); builder.AppendLine(ProjectRoot); + builder.AppendLine("-v"); + builder.AppendLine(Version); + + builder.AppendLine("-c"); + builder.AppendLine(Configuration[0].GetMetadata(Identity)); + + for (var i = 0; i < Extensions.Length; i++) + { + builder.AppendLine("-n"); + builder.AppendLine(Extensions[i].GetMetadata(Identity)); + + builder.AppendLine("-e"); + builder.AppendLine(Path.GetFullPath(Extensions[i].GetMetadata(AssemblyFilePath))); + } + return builder.ToString(); } } diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Application.cs b/src/Microsoft.AspNetCore.Razor.Tools/Application.cs index 483ab8c64c..06da6a9ec4 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/Application.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/Application.cs @@ -12,9 +12,11 @@ namespace Microsoft.AspNetCore.Razor.Tools { internal class Application : CommandLineApplication { - public Application(CancellationToken cancellationToken) + public Application(CancellationToken cancellationToken, ExtensionAssemblyLoader loader, ExtensionDependencyChecker checker) { CancellationToken = cancellationToken; + Checker = checker; + Loader = loader; Name = "rzc"; FullName = "Microsoft ASP.NET Core Razor CLI tool"; @@ -31,6 +33,10 @@ namespace Microsoft.AspNetCore.Razor.Tools public CancellationToken CancellationToken { get; } + public ExtensionAssemblyLoader Loader { get; } + + public ExtensionDependencyChecker Checker { get; } + public new int Execute(params string[] args) { try diff --git a/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs b/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs index c4b96df6b2..553e9f5a87 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/CompilerHost.cs @@ -19,6 +19,21 @@ namespace Microsoft.AspNetCore.Razor.Tools private class DefaultCompilerHost : CompilerHost { + public DefaultCompilerHost() + { + // The loader needs to live for the lifetime of the server. + // + // This means that if a request tries to use a set of binaries that are inconsistent with what + // the server already has, then it will be rejected to try again on the client. + // + // We also check each set of extensions for missing depenencies individually, so that we can + // consistently reject a request that doesn't specify everything it needs. Otherwise the request + // could succeed sometimes if it relies on transient state. + Loader = new DefaultExtensionAssemblyLoader(Path.Combine(Path.GetTempPath(), "Razor-Server")); + } + + public ExtensionAssemblyLoader Loader { get; } + public override ServerResponse Execute(ServerRequest request, CancellationToken cancellationToken) { if (!TryParseArguments(request, out var parsed)) @@ -28,28 +43,23 @@ namespace Microsoft.AspNetCore.Razor.Tools var exitCode = 0; var output = string.Empty; - var app = new Application(cancellationToken); var commandArgs = parsed.args.ToArray(); + var writer = ServerLogger.IsLoggingEnabled ? new StringWriter() : TextWriter.Null; + + var checker = new DefaultExtensionDependencyChecker(Loader, writer); + var app = new Application(cancellationToken, Loader, checker) + { + Out = writer, + Error = writer, + }; + + exitCode = app.Execute(commandArgs); + if (ServerLogger.IsLoggingEnabled) { - using (var writer = new StringWriter()) - { - app.Out = writer; - app.Error = writer; - exitCode = app.Execute(commandArgs); - output = writer.ToString(); - ServerLogger.Log(output); - } - } - else - { - using (var writer = new StreamWriter(Stream.Null)) - { - app.Out = writer; - app.Error = writer; - exitCode = app.Execute(commandArgs); - } + output = writer.ToString(); + ServerLogger.Log(output); } return new CompletedServerResponse(exitCode, utf8output: false, output: string.Empty); diff --git a/src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionAssemblyLoader.cs b/src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionAssemblyLoader.cs new file mode 100644 index 0000000000..05dc4a09eb --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionAssemblyLoader.cs @@ -0,0 +1,241 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.IO; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.Loader; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal class DefaultExtensionAssemblyLoader : ExtensionAssemblyLoader + { + private readonly string _baseDirectory; + + private readonly object _lock = new object(); + private readonly Dictionary _loadedByPath; + private readonly Dictionary _loadedByIdentity; + private readonly Dictionary _identityCache; + private readonly Dictionary> _wellKnownAssemblies; + + private ShadowCopyManager _shadowCopyManager; + + public DefaultExtensionAssemblyLoader(string baseDirectory) + { + _baseDirectory = baseDirectory; + + _loadedByPath = new Dictionary(StringComparer.OrdinalIgnoreCase); + _loadedByIdentity = new Dictionary(); + _identityCache = new Dictionary(StringComparer.OrdinalIgnoreCase); + _wellKnownAssemblies = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + LoadContext = new ExtensionAssemblyLoadContext(AssemblyLoadContext.GetLoadContext(typeof(ExtensionAssemblyLoader).Assembly), this); + } + + protected AssemblyLoadContext LoadContext { get; } + + public override void AddAssemblyLocation(string filePath) + { + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!Path.IsPathRooted(filePath)) + { + throw new ArgumentException(nameof(filePath)); + } + + var assemblyName = Path.GetFileNameWithoutExtension(filePath); + lock (_lock) + { + if (!_wellKnownAssemblies.TryGetValue(assemblyName, out var paths)) + { + paths = new List(); + _wellKnownAssemblies.Add(assemblyName, paths); + } + + if (!paths.Contains(filePath)) + { + paths.Add(filePath); + } + } + } + + public override Assembly Load(string assemblyName) + { + if (!AssemblyIdentity.TryParseDisplayName(assemblyName, out var identity)) + { + return null; + } + + lock (_lock) + { + // First, check if this loader already loaded the requested assembly: + if (_loadedByIdentity.TryGetValue(identity, out var assembly)) + { + return assembly; + } + + // Second, check if an assembly file of the same simple name was registered with the loader: + if (_wellKnownAssemblies.TryGetValue(identity.Name, out var paths)) + { + // Multiple assemblies of the same simple name but different identities might have been registered. + // Load the one that matches the requested identity (if any). + foreach (var path in paths) + { + var candidateIdentity = GetIdentity(path); + + if (identity.Equals(candidateIdentity)) + { + return LoadFromPathUnsafe(path, candidateIdentity); + } + } + } + + // We only support loading by name from 'well-known' paths. If you need to load something by + // name and you get here, then that means we don't know where to look. + return null; + } + } + + public override Assembly LoadFromPath(string filePath) + { + if (filePath == null) + { + throw new ArgumentNullException(nameof(filePath)); + } + + if (!Path.IsPathRooted(filePath)) + { + throw new ArgumentException(nameof(filePath)); + } + + lock (_lock) + { + return LoadFromPathUnsafe(filePath, identity: null); + } + } + + private Assembly LoadFromPathUnsafe(string filePath, AssemblyIdentity identity) + { + // If we've already loaded the assembly by path there should be nothing else to do, + // all of our data is up to date. + if (_loadedByPath.TryGetValue(filePath, out var entry)) + { + return entry.assembly; + } + + // If we've already loaded the assembly by identity, then we might has some updating + // to do. + identity = identity ?? GetIdentity(filePath); + if (identity != null && _loadedByIdentity.TryGetValue(identity, out var assembly)) + { + // An assembly file might be replaced by another file with a different identity. + // Last one wins. + _loadedByPath[filePath] = (assembly, identity); + return assembly; + } + + // Ok we don't have this cached. Let's actually try to load the assembly. + assembly = LoadFromPathUnsafeCore(CopyAssembly(filePath)); + + identity = identity ?? AssemblyIdentity.FromAssemblyDefinition(assembly); + + // It's possible an assembly was loaded by two different paths. Just use the original then. + if (_loadedByIdentity.TryGetValue(identity, out var duplicate)) + { + assembly = duplicate; + } + else + { + _loadedByIdentity.Add(identity, assembly); + } + + _loadedByPath[filePath] = (assembly, identity); + return assembly; + } + + private AssemblyIdentity GetIdentity(string filePath) + { + if (!_identityCache.TryGetValue(filePath, out var identity)) + { + identity = ReadAssemblyIdentity(filePath); + _identityCache.Add(filePath, identity); + } + + return identity; + } + + protected virtual string CopyAssembly(string filePath) + { + if (_baseDirectory == null) + { + // Don't shadow-copy when base directory is null. This means we're running as a CLI not + // a server. + return filePath; + } + + if (_shadowCopyManager == null) + { + _shadowCopyManager = new ShadowCopyManager(_baseDirectory); + } + + return _shadowCopyManager.AddAssembly(filePath); + } + + protected virtual Assembly LoadFromPathUnsafeCore(string filePath) + { + return LoadContext.LoadFromAssemblyPath(filePath); + } + + private static AssemblyIdentity ReadAssemblyIdentity(string filePath) + { + try + { + using (var stream = new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete)) + using (var reader = new PEReader(stream)) + { + var metadataReader = reader.GetMetadataReader(); + return metadataReader.GetAssemblyIdentity(); + } + } + catch + { + } + + return null; + } + + private class ExtensionAssemblyLoadContext : AssemblyLoadContext + { + private readonly AssemblyLoadContext _parent; + private readonly DefaultExtensionAssemblyLoader _loader; + + public ExtensionAssemblyLoadContext(AssemblyLoadContext parent, DefaultExtensionAssemblyLoader loader) + { + _parent = parent; + _loader = loader; + } + + protected override Assembly Load(AssemblyName assemblyName) + { + // Try to load from well-known paths. This will be called when loading a dependency of an extension. + var assembly = _loader.Load(assemblyName.ToString()); + if (assembly != null) + { + return assembly; + } + + // If we don't have an entry, then fall back to the default load context. This allows extensions + // to resolve assemblies that are provided by the host. + return _parent.LoadFromAssemblyName(assemblyName); + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionDependencyChecker.cs b/src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionDependencyChecker.cs new file mode 100644 index 0000000000..f5ce49abac --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/DefaultExtensionDependencyChecker.cs @@ -0,0 +1,155 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal class DefaultExtensionDependencyChecker : ExtensionDependencyChecker + { + // These are treated as prefixes. So `Microsoft.CodeAnalysis.Razor` would be assumed to work. + private static readonly string[] DefaultIgnoredAssemblies = new string[] + { + "mscorlib", + "netstandard", + "System", + "Microsoft.CodeAnalysis", + "Microsoft.AspNetCore.Razor.Language", + }; + + private readonly ExtensionAssemblyLoader _loader; + private readonly TextWriter _output; + private readonly string[] _ignoredAssemblies; + + public DefaultExtensionDependencyChecker( + ExtensionAssemblyLoader loader, + TextWriter output, + string[] ignoredAssemblies = null) + { + _loader = loader; + _output = output; + _ignoredAssemblies = ignoredAssemblies ?? DefaultIgnoredAssemblies; + } + + public override bool Check(IEnumerable assmblyFilePaths) + { + try + { + return CheckCore(assmblyFilePaths); + } + catch (Exception ex) + { + _output.WriteLine("Exception performing Extension dependency check:"); + _output.WriteLine(ex.ToString()); + return false; + } + } + + private bool CheckCore(IEnumerable assemblyFilePaths) + { + var items = assemblyFilePaths.Select(a => ExtensionVerificationItem.Create(a)).ToArray(); + var assemblies = new HashSet(items.Select(i => i.Identity)); + + for (var i = 0; i < items.Length; i++) + { + var item = items[i]; + _output.WriteLine($"Verifying assembly at {item.FilePath}"); + + if (!Path.IsPathRooted(item.FilePath)) + { + _output.WriteLine($"The file path '{item.FilePath}' is not a rooted path. File paths must be absolute and fully-qualified."); + return false; + } + + foreach (var reference in item.References) + { + if (_ignoredAssemblies.Any(n => reference.Name.StartsWith(n))) + { + // This is on the allow list, keep going. + continue; + } + + if (assemblies.Contains(reference)) + { + // This was also provided as a dependency, keep going. + continue; + } + + // If we get here we can't resolve this assembly. This is an error. + _output.WriteLine($"Extension assembly '{item.Identity.Name}' depends on '{reference.ToString()} which is missing."); + return false; + } + } + + // Assuming we get this far, the set of assemblies we have is at least a coherent set (barring + // version conflicts). Register all of the paths with the loader so they can find each other by + // name. + for (var i = 0; i < items.Length; i++) + { + _loader.AddAssemblyLocation(items[i].FilePath); + } + + // Now try to load everything. This has the side effect of resolving all of these items + // in the loader's caches. + for (var i = 0; i < items.Length; i++) + { + var item = items[i]; + item.Assembly = _loader.LoadFromPath(item.FilePath); + } + + // Third, check that the MVIDs of the files on disk match the MVIDs of the loaded assemblies. + for (var i = 0; i < items.Length; i++) + { + var item = items[i]; + if (item.Mvid != item.Assembly.ManifestModule.ModuleVersionId) + { + _output.WriteLine($"Extension assembly '{item.Identity.Name}' at '{item.FilePath}' has a different ModuleVersionId than loaded assembly '{item.Assembly.FullName}'"); + return false; + } + } + + return true; + } + + private class ExtensionVerificationItem + { + public static ExtensionVerificationItem Create(string filePath) + { + using (var peReader = new PEReader(new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read))) + { + var metadataReader = peReader.GetMetadataReader(); + var identity = metadataReader.GetAssemblyIdentity(); + var mvid = metadataReader.GetGuid(metadataReader.GetModuleDefinition().Mvid); + var references = metadataReader.GetReferencedAssembliesOrThrow(); + + return new ExtensionVerificationItem(filePath, identity, mvid, references.ToArray()); + } + } + + private ExtensionVerificationItem(string filePath, AssemblyIdentity identity, Guid mvid, AssemblyIdentity[] references) + { + FilePath = filePath; + Identity = identity; + Mvid = mvid; + References = references; + } + + public string FilePath { get; } + + public Assembly Assembly { get; set; } + + public AssemblyIdentity Identity { get; } + + public Guid Mvid { get; } + + public IReadOnlyList References { get; } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs index 2b3cee612e..9ca5b3d8bb 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/DiscoverCommand.cs @@ -8,7 +8,6 @@ using System.Linq; using System.Security.Cryptography; using System.Text; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Razor.Extensions; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor; @@ -26,6 +25,10 @@ namespace Microsoft.AspNetCore.Razor.Tools Assemblies = Argument("assemblies", "assemblies to search for tag helpers", multipleValues: true); TagHelperManifest = Option("-o", "output file", CommandOptionType.SingleValue); ProjectDirectory = Option("-p", "project root directory", CommandOptionType.SingleValue); + Version = Option("-v|--version", "Razor language version", CommandOptionType.SingleValue); + Configuration = Option("-c", "Razor configuration name", CommandOptionType.SingleValue); + ExtensionNames = Option("-n", "extension name", CommandOptionType.MultipleValue); + ExtensionFilePaths = Option("-e", "extension file path", CommandOptionType.MultipleValue); } public CommandArgument Assemblies { get; } @@ -34,6 +37,14 @@ namespace Microsoft.AspNetCore.Razor.Tools public CommandOption ProjectDirectory { get; } + public CommandOption Version { get; } + + public CommandOption Configuration { get; } + + public CommandOption ExtensionNames { get; } + + public CommandOption ExtensionFilePaths { get; } + protected override bool ValidateArguments() { if (string.IsNullOrEmpty(TagHelperManifest.Value())) @@ -53,12 +64,61 @@ namespace Microsoft.AspNetCore.Razor.Tools ProjectDirectory.Values.Add(Environment.CurrentDirectory); } + if (string.IsNullOrEmpty(Version.Value())) + { + Error.WriteLine($"{Version.ValueName} must be specified."); + return false; + } + else if (!RazorLanguageVersion.TryParse(Version.Value(), out _)) + { + Error.WriteLine($"{Version.ValueName} is not a valid language version."); + return false; + } + + if (string.IsNullOrEmpty(Configuration.Value())) + { + Error.WriteLine($"{Configuration.ValueName} must be specified."); + return false; + } + + if (ExtensionNames.Values.Count != ExtensionFilePaths.Values.Count) + { + Error.WriteLine($"{ExtensionNames.ValueName} and {ExtensionFilePaths.ValueName} should have the same number of values."); + } + + foreach (var filePath in ExtensionFilePaths.Values) + { + if (!Path.IsPathRooted(filePath)) + { + Error.WriteLine($"Extension file paths must be fully-qualified, absolute paths."); + return false; + } + } + + if (!Parent.Checker.Check(ExtensionFilePaths.Values)) + { + Error.WriteLine($"Extenions could not be loaded. See output for details."); + return false; + } + return true; } protected override Task ExecuteCoreAsync() { + // Loading all of the extensions should succeed as the dependency checker will have already + // loaded them. + var extensions = new RazorExtension[ExtensionNames.Values.Count]; + for (var i = 0; i < ExtensionNames.Values.Count; i++) + { + extensions[i] = new AssemblyExtension(ExtensionNames.Values[i], Parent.Loader.LoadFromPath(ExtensionFilePaths.Values[i])); + } + + var version = RazorLanguageVersion.Parse(Version.Value()); + var configuration = new RazorConfiguration(version, Configuration.Value(), extensions); + var result = ExecuteCore( + configuration: configuration, projectDirectory: ProjectDirectory.Value(), outputFilePath: TagHelperManifest.Value(), assemblies: Assemblies.Values.ToArray()); @@ -66,7 +126,7 @@ namespace Microsoft.AspNetCore.Razor.Tools return Task.FromResult(result); } - private int ExecuteCore(string projectDirectory, string outputFilePath, string[] assemblies) + private int ExecuteCore(RazorConfiguration configuration, string projectDirectory, string outputFilePath, string[] assemblies) { outputFilePath = Path.Combine(projectDirectory, outputFilePath); @@ -76,19 +136,14 @@ namespace Microsoft.AspNetCore.Razor.Tools metadataReferences[i] = MetadataReference.CreateFromFile(assemblies[i]); } - var engine = RazorEngine.Create((b) => + var engine = RazorProjectEngine.Create(configuration, RazorProjectFileSystem.Empty, b => { - RazorExtensions.Register(b); - b.Features.Add(new DefaultMetadataReferenceFeature() { References = metadataReferences }); b.Features.Add(new CompilationTagHelperFeature()); - - // TagHelperDescriptorProviders (actually do tag helper discovery) b.Features.Add(new DefaultTagHelperDescriptorProvider()); - b.Features.Add(new ViewComponentTagHelperDescriptorProvider()); }); - var feature = engine.Features.OfType().Single(); + var feature = engine.Engine.Features.OfType().Single(); var tagHelpers = feature.GetDescriptors(); using (var stream = new MemoryStream()) diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ExtensionAssemblyLoader.cs b/src/Microsoft.AspNetCore.Razor.Tools/ExtensionAssemblyLoader.cs new file mode 100644 index 0000000000..071eec2f82 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ExtensionAssemblyLoader.cs @@ -0,0 +1,16 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Reflection; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal abstract class ExtensionAssemblyLoader + { + public abstract void AddAssemblyLocation(string filePath); + + public abstract Assembly Load(string assemblyName); + + public abstract Assembly LoadFromPath(string filePath); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ExtensionDependencyChecker.cs b/src/Microsoft.AspNetCore.Razor.Tools/ExtensionDependencyChecker.cs new file mode 100644 index 0000000000..02fd86d9e8 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ExtensionDependencyChecker.cs @@ -0,0 +1,12 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal abstract class ExtensionDependencyChecker + { + public abstract bool Check(IEnumerable extensionFilePaths); + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/GenerateCommand.cs b/src/Microsoft.AspNetCore.Razor.Tools/GenerateCommand.cs index 0981cc1a03..d19c566b76 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/GenerateCommand.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/GenerateCommand.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Razor.Extensions; using Microsoft.AspNetCore.Razor.Language; using Microsoft.Extensions.CommandLineUtils; using Microsoft.VisualStudio.LanguageServices.Razor; @@ -14,13 +13,6 @@ using Newtonsoft.Json; namespace Microsoft.AspNetCore.Razor.Tools { - internal class Builder - { - public static Builder Make(CommandBase result) => null; - - public static Builder Make(T result) => null; - } - internal class GenerateCommand : CommandBase { public GenerateCommand(Application parent) @@ -31,6 +23,10 @@ namespace Microsoft.AspNetCore.Razor.Tools RelativePaths = Option("-r", "Relative path", CommandOptionType.MultipleValue); ProjectDirectory = Option("-p", "project root directory", CommandOptionType.SingleValue); TagHelperManifest = Option("-t", "tag helper manifest file", CommandOptionType.SingleValue); + Version = Option("-v|--version", "Razor language version", CommandOptionType.SingleValue); + Configuration = Option("-c", "Razor configuration name", CommandOptionType.SingleValue); + ExtensionNames = Option("-n", "extension name", CommandOptionType.MultipleValue); + ExtensionFilePaths = Option("-e", "extension file path", CommandOptionType.MultipleValue); } public CommandOption Sources { get; } @@ -43,9 +39,29 @@ namespace Microsoft.AspNetCore.Razor.Tools public CommandOption TagHelperManifest { get; } + public CommandOption Version { get; } + + public CommandOption Configuration { get; } + + public CommandOption ExtensionNames { get; } + + public CommandOption ExtensionFilePaths { get; } + protected override Task ExecuteCoreAsync() { + // Loading all of the extensions should succeed as the dependency checker will have already + // loaded them. + var extensions = new RazorExtension[ExtensionNames.Values.Count]; + for (var i = 0; i < ExtensionNames.Values.Count; i++) + { + extensions[i] = new AssemblyExtension(ExtensionNames.Values[i], Parent.Loader.LoadFromPath(ExtensionFilePaths.Values[i])); + } + + var version = RazorLanguageVersion.Parse(Version.Value()); + var configuration = new RazorConfiguration(version, Configuration.Value(), extensions); + var result = ExecuteCore( + configuration: configuration, projectDirectory: ProjectDirectory.Value(), tagHelperManifest: TagHelperManifest.Value(), sources: Sources.Values, @@ -78,10 +94,48 @@ namespace Microsoft.AspNetCore.Razor.Tools ProjectDirectory.Values.Add(Environment.CurrentDirectory); } + if (string.IsNullOrEmpty(Version.Value())) + { + Error.WriteLine($"{Version.ValueName} must be specified."); + return false; + } + else if (!RazorLanguageVersion.TryParse(Version.Value(), out _)) + { + Error.WriteLine($"{Version.ValueName} is not a valid language version."); + return false; + } + + if (string.IsNullOrEmpty(Configuration.Value())) + { + Error.WriteLine($"{Configuration.ValueName} must be specified."); + return false; + } + + if (ExtensionNames.Values.Count != ExtensionFilePaths.Values.Count) + { + Error.WriteLine($"{ExtensionNames.ValueName} and {ExtensionFilePaths.ValueName} should have the same number of values."); + } + + foreach (var filePath in ExtensionFilePaths.Values) + { + if (!Path.IsPathRooted(filePath)) + { + Error.WriteLine($"Extension file paths must be fully-qualified, absolute paths."); + return false; + } + } + + if (!Parent.Checker.Check(ExtensionFilePaths.Values)) + { + Error.WriteLine($"Extensions could not be loaded. See output for details."); + return false; + } + return true; } private int ExecuteCore( + RazorConfiguration configuration, string projectDirectory, string tagHelperManifest, List sources, @@ -97,14 +151,13 @@ namespace Microsoft.AspNetCore.Razor.Tools GetVirtualRazorProjectSystem(inputItems), RazorProjectFileSystem.Create(projectDirectory), }); - var projectEngine = RazorProjectEngine.Create(RazorConfiguration.Default, compositeFileSystem, b => + + var engine = RazorProjectEngine.Create(configuration, compositeFileSystem, b => { - RazorExtensions.Register(b); - b.Features.Add(new StaticTagHelperFeature() { TagHelpers = tagHelpers, }); }); - var results = GenerateCode(projectEngine, inputItems); + var results = GenerateCode(engine, inputItems); var success = true; @@ -175,14 +228,14 @@ namespace Microsoft.AspNetCore.Razor.Tools return items; } - private OutputItem[] GenerateCode(RazorProjectEngine projectEngine, SourceItem[] inputs) + private OutputItem[] GenerateCode(RazorProjectEngine engine, SourceItem[] inputs) { var outputs = new OutputItem[inputs.Length]; Parallel.For(0, outputs.Length, new ParallelOptions() { MaxDegreeOfParallelism = Debugger.IsAttached ? 1 : 4 }, i => { var inputItem = inputs[i]; - var projectItem = projectEngine.FileSystem.GetItem(inputItem.FilePath); - var codeDocument = projectEngine.Process(projectItem); + + var codeDocument = engine.Process(engine.FileSystem.GetItem(inputItem.FilePath)); var csharpDocument = codeDocument.GetCSharpDocument(); outputs[i] = new OutputItem(inputItem, csharpDocument); }); diff --git a/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs b/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs new file mode 100644 index 0000000000..da1bddb865 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/MetadataReaderExtensions.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Reflection; +using System.Reflection.Metadata; +using Microsoft.CodeAnalysis; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal static class MetadataReaderExtensions + { + internal static AssemblyIdentity GetAssemblyIdentity(this MetadataReader reader) + { + if (!reader.IsAssembly) + { + throw new BadImageFormatException(); + } + + var definition = reader.GetAssemblyDefinition(); + + return CreateAssemblyIdentity( + reader, + definition.Version, + definition.Flags, + definition.PublicKey, + definition.Name, + definition.Culture, + isReference: false); + } + + internal static AssemblyIdentity[] GetReferencedAssembliesOrThrow(this MetadataReader reader) + { + var references = new List(); + + foreach (var referenceHandle in reader.AssemblyReferences) + { + var reference = reader.GetAssemblyReference(referenceHandle); + references.Add(CreateAssemblyIdentity( + reader, + reference.Version, + reference.Flags, + reference.PublicKeyOrToken, + reference.Name, + reference.Culture, + isReference: true)); + } + + return references.ToArray(); + } + + private static AssemblyIdentity CreateAssemblyIdentity( + MetadataReader reader, + Version version, + AssemblyFlags flags, + BlobHandle publicKey, + StringHandle name, + StringHandle culture, + bool isReference) + { + var publicKeyOrToken = reader.GetBlobContent(publicKey); + bool hasPublicKey; + + if (isReference) + { + hasPublicKey = (flags & AssemblyFlags.PublicKey) != 0; + } + else + { + // Assembly definitions never contain a public key token, they only can have a full key or nothing, + // so the flag AssemblyFlags.PublicKey does not make sense for them and is ignored. + // See Ecma-335, Partition II Metadata, 22.2 "Assembly : 0x20". + // This also corresponds to the behavior of the native C# compiler and sn.exe tool. + hasPublicKey = !publicKeyOrToken.IsEmpty; + } + + if (publicKeyOrToken.IsEmpty) + { + publicKeyOrToken = default; + } + + return new AssemblyIdentity( + name: reader.GetString(name), + version: version, + cultureName: culture.IsNil ? null : reader.GetString(culture), + publicKeyOrToken: publicKeyOrToken, + hasPublicKey: hasPublicKey, + isRetargetable: (flags & AssemblyFlags.Retargetable) != 0, + contentType: (AssemblyContentType)((int)(flags & AssemblyFlags.ContentTypeMask) >> 9)); + } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj b/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj index fe2b7c52b9..6aae7e8d50 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj +++ b/src/Microsoft.AspNetCore.Razor.Tools/Microsoft.AspNetCore.Razor.Tools.csproj @@ -27,7 +27,7 @@ - + diff --git a/src/Microsoft.AspNetCore.Razor.Tools/Program.cs b/src/Microsoft.AspNetCore.Razor.Tools/Program.cs index 8a23dd91b9..0b3b980c3a 100644 --- a/src/Microsoft.AspNetCore.Razor.Tools/Program.cs +++ b/src/Microsoft.AspNetCore.Razor.Tools/Program.cs @@ -15,7 +15,11 @@ namespace Microsoft.AspNetCore.Razor.Tools var cancel = new CancellationTokenSource(); Console.CancelKeyPress += (sender, e) => { cancel.Cancel(); }; - var application = new Application(cancel.Token); + // Prevent shadow copying. + var loader = new DefaultExtensionAssemblyLoader(baseDirectory: null); + var checker = new DefaultExtensionDependencyChecker(loader, Console.Error); + + var application = new Application(cancel.Token, loader, checker); return application.Execute(args); } } diff --git a/src/Microsoft.AspNetCore.Razor.Tools/ShadowCopyManager.cs b/src/Microsoft.AspNetCore.Razor.Tools/ShadowCopyManager.cs new file mode 100644 index 0000000000..317dc5bd74 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Tools/ShadowCopyManager.cs @@ -0,0 +1,169 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + // Note that this class has no thread-safety guarantees. The caller should use a lock + // if concurrency is required. + internal class ShadowCopyManager : IDisposable + { + // Note that this class uses the *existance* of the Mutex to lock a directory. + // + // Nothing in this code actually ever acquires the Mutex, we just try to see if it exists + // already. + private readonly Mutex _mutex; + + private int _counter; + + public ShadowCopyManager(string baseDirectory = null) + { + BaseDirectory = baseDirectory ?? Path.Combine(Path.GetTempPath(), "Razor", "ShadowCopy"); + + var guid = Guid.NewGuid().ToString("N").ToLowerInvariant(); + UniqueDirectory = Path.Combine(BaseDirectory, guid); + + _mutex = new Mutex(initiallyOwned: false, name: guid); + + Directory.CreateDirectory(UniqueDirectory); + } + + public string BaseDirectory { get; } + + public string UniqueDirectory { get; } + + public string AddAssembly(string filePath) + { + var assemblyDirectory = CreateUniqueDirectory(); + + var destination = Path.Combine(assemblyDirectory, Path.GetFileName(filePath)); + CopyFile(filePath, destination); + + var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(filePath); + var resourcesNameWithoutExtension = fileNameWithoutExtension + ".resources"; + var resourcesNameWithExtension = resourcesNameWithoutExtension + ".dll"; + + foreach (var directory in Directory.EnumerateDirectories(Path.GetDirectoryName(filePath))) + { + var directoryName = Path.GetFileName(directory); + + var resourcesPath = Path.Combine(directory, resourcesNameWithExtension); + if (File.Exists(resourcesPath)) + { + var resourcesShadowCopyPath = Path.Combine(assemblyDirectory, directoryName, resourcesNameWithExtension); + CopyFile(resourcesPath, resourcesShadowCopyPath); + } + + resourcesPath = Path.Combine(directory, resourcesNameWithoutExtension, resourcesNameWithExtension); + if (File.Exists(resourcesPath)) + { + var resourcesShadowCopyPath = Path.Combine(assemblyDirectory, directoryName, resourcesNameWithoutExtension, resourcesNameWithExtension); + CopyFile(resourcesPath, resourcesShadowCopyPath); + } + } + + return destination; + } + + public void Dispose() + { + _mutex.ReleaseMutex(); + } + + public Task PurgeUnusedDirectoriesAsync() + { + return Task.Run((Action)PurgeUnusedDirectories); + } + + private string CreateUniqueDirectory() + { + var id = _counter++; + + var directory = Path.Combine(UniqueDirectory, id.ToString()); + Directory.CreateDirectory(directory); + return directory; + } + + private void CopyFile(string originalPath, string shadowCopyPath) + { + var directory = Path.GetDirectoryName(shadowCopyPath); + Directory.CreateDirectory(directory); + + File.Copy(originalPath, shadowCopyPath); + + MakeWritable(new FileInfo(shadowCopyPath)); + } + + private void MakeWritable(string directoryPath) + { + var directory = new DirectoryInfo(directoryPath); + + foreach (var file in directory.EnumerateFiles(searchPattern: "*", searchOption: SearchOption.AllDirectories)) + { + MakeWritable(file); + } + } + + private void MakeWritable(FileInfo file) + { + try + { + if (file.IsReadOnly) + { + file.IsReadOnly = false; + } + } + catch + { + // There are many reasons this could fail. Ignore it and keep going. + } + } + + private void PurgeUnusedDirectories() + { + IEnumerable directories; + try + { + directories = Directory.EnumerateDirectories(BaseDirectory); + } + catch (DirectoryNotFoundException) + { + return; + } + + foreach (var directory in directories) + { + Mutex mutex = null; + try + { + // We only want to try deleting the directory if no-one else is currently using it. + // + // Note that the mutex name is the name of the directory. This is OK because we're using + // GUIDs as directory/mutex names. + if (!Mutex.TryOpenExisting(Path.GetFileName(directory).ToLowerInvariant(), out mutex)) + { + MakeWritable(directory); + Directory.Delete(directory, recursive: true); + } + } + catch + { + // If something goes wrong we will leave it to the next run to clean up. + // Just swallow the exception and move on. + } + finally + { + if (mutex != null) + { + mutex.Dispose(); + } + } + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs b/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs index f63de011a4..d2d0aa98e5 100644 --- a/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs +++ b/test/Microsoft.AspNetCore.Razor.Design.Test/IntegrationTests/BuildServerTestFixture.cs @@ -5,6 +5,7 @@ using System; using System.IO; using System.Threading; using Microsoft.AspNetCore.Razor.Tools; +using Moq; namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests { @@ -34,7 +35,7 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests throw new TimeoutException($"Shutting down the build server at pipe {PipeName} took longer than expected."); }); - var application = new Application(cts.Token); + var application = new Application(cts.Token, Mock.Of(), Mock.Of()); var exitCode = application.Execute("shutdown", "-w", "-p", PipeName); if (exitCode != 0) { diff --git a/test/Microsoft.AspNetCore.Razor.Test.Common/Language/TestRazorProjectFileSystem.cs b/test/Microsoft.AspNetCore.Razor.Test.Common/Language/TestRazorProjectFileSystem.cs index 39ff443eb5..f16fff0274 100644 --- a/test/Microsoft.AspNetCore.Razor.Test.Common/Language/TestRazorProjectFileSystem.cs +++ b/test/Microsoft.AspNetCore.Razor.Test.Common/Language/TestRazorProjectFileSystem.cs @@ -9,7 +9,7 @@ namespace Microsoft.AspNetCore.Razor.Language { internal class TestRazorProjectFileSystem : DefaultRazorProjectFileSystem { - public static RazorProjectFileSystem Empty = new TestRazorProjectFileSystem(); + public new static RazorProjectFileSystem Empty = new TestRazorProjectFileSystem(); private readonly Dictionary _lookup; diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionAssemblyLoaderTest.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionAssemblyLoaderTest.cs new file mode 100644 index 0000000000..ad619e5570 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionAssemblyLoaderTest.cs @@ -0,0 +1,128 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Text; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + public class DefaultExtensionAssemblyLoaderTest + { + [Fact] + public void LoadFromPath_CanLoadAssembly() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + + // Act + var assembly = loader.LoadFromPath(alphaFilePath); + + // Assert + Assert.NotNull(assembly); + } + } + + [Fact] + public void LoadFromPath_DoesNotAddDuplicates_AfterLoadingByName() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + var alphaFilePath2 = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha2.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + loader.AddAssemblyLocation(alphaFilePath); + + var assembly1 = loader.Load("Alpha"); + + // Act + var assembly2 = loader.LoadFromPath(alphaFilePath2); + + // Assert + Assert.Same(assembly1, assembly2); + } + } + + [Fact] + public void LoadFromPath_DoesNotAddDuplicates_AfterLoadingByPath() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + var alphaFilePath2 = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha2.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + var assembly1 = loader.LoadFromPath(alphaFilePath); + + // Act + var assembly2 = loader.LoadFromPath(alphaFilePath2); + + // Assert + Assert.Same(assembly1, assembly2); + } + } + + [Fact] + public void Load_CanLoadAssemblyByName_AfterLoadingByPath() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + var assembly1 = loader.LoadFromPath(alphaFilePath); + + // Act + var assembly2 = loader.Load(assembly1.FullName); + + // Assert + Assert.Same(assembly1, assembly2); + } + } + + [Fact] + public void LoadFromPath_WithDependencyPathsSpecified_CanLoadAssemblyDependencies() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + var betaFilePath = LoaderTestResources.Beta.WriteToFile(directory.DirectoryPath, "Beta.dll"); + var gammaFilePath = LoaderTestResources.Gamma.WriteToFile(directory.DirectoryPath, "Gamma.dll"); + var deltaFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + loader.AddAssemblyLocation(gammaFilePath); + loader.AddAssemblyLocation(deltaFilePath); + + // Act + var alpha = loader.LoadFromPath(alphaFilePath); + var beta = loader.LoadFromPath(betaFilePath); + + // Assert + var builder = new StringBuilder(); + + var a = alpha.CreateInstance("Alpha.A"); + a.GetType().GetMethod("Write").Invoke(a, new object[] { builder, "Test A" }); + + var b = beta.CreateInstance("Beta.B"); + b.GetType().GetMethod("Write").Invoke(b, new object[] { builder, "Test B" }); + var expected = @"Delta: Gamma: Alpha: Test A +Delta: Gamma: Beta: Test B +"; + + var actual = builder.ToString(); + + Assert.Equal(expected, actual); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionDependencyCheckerTest.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionDependencyCheckerTest.cs new file mode 100644 index 0000000000..72d719fdc8 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/DefaultExtensionDependencyCheckerTest.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using Moq; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + public class DefaultExtensionDependencyCheckerTest + { + [Fact] + public void Check_ReturnsFalse_WithMissingDependency() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var output = new StringWriter(); + + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + var checker = new DefaultExtensionDependencyChecker(loader, output); + + // Act + var result = checker.Check(new[] { alphaFilePath, }); + + // Assert + Assert.False(result, "Check should not have passed: " + output.ToString()); + } + } + + [Fact] + public void Check_ReturnsTrue_WithAllDependenciesProvided() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var output = new StringWriter(); + + var alphaFilePath = LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + var betaFilePath = LoaderTestResources.Beta.WriteToFile(directory.DirectoryPath, "Beta.dll"); + var gammaFilePath = LoaderTestResources.Gamma.WriteToFile(directory.DirectoryPath, "Gamma.dll"); + var deltaFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + var checker = new DefaultExtensionDependencyChecker(loader, output); + + // Act + var result = checker.Check(new[] { alphaFilePath, betaFilePath, gammaFilePath, deltaFilePath, }); + + // Assert + Assert.True(result, "Check should have passed: " + output.ToString()); + } + } + + [Fact] + public void Check_ReturnsFalse_WhenAssemblyHasDifferentMVID() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var output = new StringWriter(); + + // Load Beta.dll from the future Alpha.dll path to prime the assembly loader + var alphaFilePath = LoaderTestResources.Beta.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + var betaFilePath = LoaderTestResources.Beta.WriteToFile(directory.DirectoryPath, "Beta.dll"); + var gammaFilePath = LoaderTestResources.Gamma.WriteToFile(directory.DirectoryPath, "Gamma.dll"); + var deltaFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll"); + + var loader = new TestDefaultExtensionAssemblyLoader(Path.Combine(directory.DirectoryPath, "shadow")); + var checker = new DefaultExtensionDependencyChecker(loader, output); + + // This will cause the loader to cache some inconsistent information. + loader.LoadFromPath(alphaFilePath); + LoaderTestResources.Alpha.WriteToFile(directory.DirectoryPath, "Alpha.dll"); + + // Act + var result = checker.Check(new[] { alphaFilePath, gammaFilePath, deltaFilePath, }); + + // Assert + Assert.False(result, "Check should not have passed: " + output.ToString()); + } + } + + [Fact] + public void Check_ReturnsFalse_WhenLoaderThrows() + { + using (var directory = TempDirectory.Create()) + { + // Arrange + var output = new StringWriter(); + + var deltaFilePath = LoaderTestResources.Delta.WriteToFile(directory.DirectoryPath, "Delta.dll"); + + var loader = new Mock(); + loader + .Setup(l => l.LoadFromPath(It.IsAny())) + .Throws(new InvalidOperationException()); + var checker = new DefaultExtensionDependencyChecker(loader.Object, output); + + // Act + var result = checker.Check(new[] { deltaFilePath, }); + + // Assert + Assert.False(result, "Check should not have passed: " + output.ToString()); + } + } + } +} \ No newline at end of file diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/Infrastructure/ServerUtilities.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/Infrastructure/ServerUtilities.cs index 473b608142..b91723c9d0 100644 --- a/test/Microsoft.AspNetCore.Razor.Tools.Test/Infrastructure/ServerUtilities.cs +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/Infrastructure/ServerUtilities.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; +using Moq; namespace Microsoft.AspNetCore.Razor.Tools { @@ -116,7 +117,7 @@ namespace Microsoft.AspNetCore.Razor.Tools CancellationToken ct, EventBus eventBus, TimeSpan? keepAlive) - : base(new Application(ct)) + : base(new Application(ct, Mock.Of(), Mock.Of())) { _host = host; _compilerHost = compilerHost; diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/LoaderTestResources.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/LoaderTestResources.cs new file mode 100644 index 0000000000..9478c46684 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/LoaderTestResources.cs @@ -0,0 +1,146 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Linq; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; +using Xunit; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal static class LoaderTestResources + { + static LoaderTestResources() + { + Delta = CreateAssemblyBlob("Delta", Array.Empty(), @" +using System.Text; + +namespace Delta +{ + public class D + { + public void Write(StringBuilder sb, string s) + { + sb.AppendLine(""Delta: "" + s); + } + } +} +"); + + Gamma = CreateAssemblyBlob("Gamma", new[] { Delta, }, @" +using System.Text; +using Delta; + +namespace Gamma +{ + public class G + { + public void Write(StringBuilder sb, string s) + { + D d = new D(); + + d.Write(sb, ""Gamma: "" + s); + } + } +} +"); + + Alpha = CreateAssemblyBlob("Alpha", new[] { Gamma, }, @" +using System.Text; +using Gamma; + +namespace Alpha +{ + public class A + { + public void Write(StringBuilder sb, string s) + { + G g = new G(); + + g.Write(sb, ""Alpha: "" + s); + } + } +} +"); + + Beta = CreateAssemblyBlob("Beta", new[] { Gamma, }, @" +using System.Text; +using Gamma; + +namespace Beta +{ + public class B + { + public void Write(StringBuilder sb, string s) + { + G g = new G(); + + g.Write(sb, ""Beta: "" + s); + } + } +} +"); + } + + public static AssemblyBlob Alpha { get; } + + public static AssemblyBlob Beta { get; } + + public static AssemblyBlob Delta { get; } + + public static AssemblyBlob Gamma { get; } + + private static AssemblyBlob CreateAssemblyBlob(string assemblyName, AssemblyBlob[] references, string text) + { + var defaultReferences = new[] + { + MetadataReference.CreateFromFile(typeof(object).Assembly.Location), + }; + + var compilation = CSharpCompilation.Create( + assemblyName, + new[] { CSharpSyntaxTree.ParseText(text) }, + references.Select(r => r.ToMetadataReference()).Concat(defaultReferences), + new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary)); + + using (var assemblyStream = new MemoryStream()) + using (var symbolStream = new MemoryStream()) + { + var result = compilation.Emit(assemblyStream, symbolStream); + Assert.Empty(result.Diagnostics); + + return new AssemblyBlob(assemblyName, assemblyStream.GetBuffer(), symbolStream.GetBuffer()); + } + } + + public class AssemblyBlob + { + public AssemblyBlob(string assemblyName, byte[] assemblyBytes, byte[] symbolBytes) + { + AssemblyName = assemblyName; + AssemblyBytes = assemblyBytes; + SymbolBytes = symbolBytes; + } + + public string AssemblyName { get; } + + public byte[] AssemblyBytes { get; } + + public byte[] SymbolBytes { get; } + + public MetadataReference ToMetadataReference() + { + return MetadataReference.CreateFromImage(AssemblyBytes); + } + + internal string WriteToFile(string directoryPath, string fileName) + { + var filePath = Path.Combine(directoryPath, fileName); + File.WriteAllBytes(filePath, AssemblyBytes); + return filePath; + } + } + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/Microsoft.AspNetCore.Razor.Tools.Test.csproj b/test/Microsoft.AspNetCore.Razor.Tools.Test/Microsoft.AspNetCore.Razor.Tools.Test.csproj index 3778c1c8ca..df0bf3822a 100644 --- a/test/Microsoft.AspNetCore.Razor.Tools.Test/Microsoft.AspNetCore.Razor.Tools.Test.csproj +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/Microsoft.AspNetCore.Razor.Tools.Test.csproj @@ -17,4 +17,10 @@ + + + System + + + diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/Properties/AssemblyInfo.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..f0aa552b16 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/TempDirectory.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/TempDirectory.cs new file mode 100644 index 0000000000..d491248465 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/TempDirectory.cs @@ -0,0 +1,30 @@ +// Copyright(c) .NET Foundation.All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal class TempDirectory : IDisposable + { + public static TempDirectory Create() + { + var directoryPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("n")); + Directory.CreateDirectory(directoryPath); + return new TempDirectory(directoryPath); + } + + private TempDirectory(string directoryPath) + { + DirectoryPath = directoryPath; + } + + public string DirectoryPath { get; } + + public void Dispose() + { + Directory.Delete(DirectoryPath, recursive: true); + } + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Tools.Test/TestDefaultExtensionAssemblyLoader.cs b/test/Microsoft.AspNetCore.Razor.Tools.Test/TestDefaultExtensionAssemblyLoader.cs new file mode 100644 index 0000000000..d272e1c005 --- /dev/null +++ b/test/Microsoft.AspNetCore.Razor.Tools.Test/TestDefaultExtensionAssemblyLoader.cs @@ -0,0 +1,25 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.IO; +using System.Reflection; + +namespace Microsoft.AspNetCore.Razor.Tools +{ + internal class TestDefaultExtensionAssemblyLoader : DefaultExtensionAssemblyLoader + { + public TestDefaultExtensionAssemblyLoader(string baseDirectory) + : base(baseDirectory) + { + } + + protected override Assembly LoadFromPathUnsafeCore(string filePath) + { + // Force a load from streams so we don't lock the files on disk. This way we can test + // shadow copying without leaving a mess behind. + var bytes = File.ReadAllBytes(filePath); + var stream = new MemoryStream(bytes); + return LoadContext.LoadFromStream(stream); + } + } +} diff --git a/test/testapps/AppWithP2PReference/AppWithP2PReference.csproj b/test/testapps/AppWithP2PReference/AppWithP2PReference.csproj index fe9d5d2940..6688f30d79 100644 --- a/test/testapps/AppWithP2PReference/AppWithP2PReference.csproj +++ b/test/testapps/AppWithP2PReference/AppWithP2PReference.csproj @@ -7,6 +7,11 @@ + + + + <_MvcExtensionAssemblyPath>$(SolutionRoot)src\Microsoft.AspNetCore.Mvc.Razor.Extensions\bin\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.Mvc.Razor.Extensions.dll + diff --git a/test/testapps/ClassLibrary/ClassLibrary.csproj b/test/testapps/ClassLibrary/ClassLibrary.csproj index 443d790efa..1aa8072b38 100644 --- a/test/testapps/ClassLibrary/ClassLibrary.csproj +++ b/test/testapps/ClassLibrary/ClassLibrary.csproj @@ -8,6 +8,11 @@ + + + + <_MvcExtensionAssemblyPath>$(SolutionRoot)src\Microsoft.AspNetCore.Mvc.Razor.Extensions\bin\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.Mvc.Razor.Extensions.dll + diff --git a/test/testapps/SimpleMvc/SimpleMvc.csproj b/test/testapps/SimpleMvc/SimpleMvc.csproj index 31c006b2f3..4e20c9b1c8 100644 --- a/test/testapps/SimpleMvc/SimpleMvc.csproj +++ b/test/testapps/SimpleMvc/SimpleMvc.csproj @@ -8,6 +8,11 @@ + + + + <_MvcExtensionAssemblyPath>$(SolutionRoot)src\Microsoft.AspNetCore.Mvc.Razor.Extensions\bin\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.Mvc.Razor.Extensions.dll + diff --git a/test/testapps/SimplePages/SimplePages.csproj b/test/testapps/SimplePages/SimplePages.csproj index 340a6f14db..654b8f63dc 100644 --- a/test/testapps/SimplePages/SimplePages.csproj +++ b/test/testapps/SimplePages/SimplePages.csproj @@ -8,6 +8,11 @@ + + + + <_MvcExtensionAssemblyPath>$(SolutionRoot)src\Microsoft.AspNetCore.Mvc.Razor.Extensions\bin\$(Configuration)\netstandard2.0\Microsoft.AspNetCore.Mvc.Razor.Extensions.dll +