From 01f2f149078bf0996b7e96043d729df1098ba091 Mon Sep 17 00:00:00 2001 From: lordmilko Date: Sat, 22 Jul 2023 22:26:44 +1000 Subject: [PATCH] Initial commit --- .gitignore | 49 ++++ DllExports.MSBuild/DllExports.MSBuild.csproj | 65 +++++ DllExports.MSBuild/GenerateDllExports.cs | 137 +++++++++ DllExports.MSBuild/IsolatedTaskRunner.cs | 64 +++++ .../NetCoreAssemblyLoadContext.cs | 71 +++++ .../NetFrameworkAssemblyLoadContext.cs | 81 ++++++ DllExports.MSBuild/RemoteHelper.cs | 66 +++++ DllExports.MSBuild/build/DllExports.props | 11 + DllExports.MSBuild/build/DllExports.targets | 36 +++ DllExports.Tests/AssertEx.cs | 27 ++ DllExports.Tests/DllExports.Tests.csproj | 23 ++ DllExports.Tests/ExporterTests.cs | 158 +++++++++++ DllExports.Tests/MockBuildEngine.cs | 37 +++ DllExports.sln | 37 +++ DllExports/DllExportAttribute.cs | 30 ++ DllExports/DllExports.csproj | 17 ++ DllExports/ExportOptions.cs | 85 ++++++ DllExports/Exporter.cs | 175 ++++++++++++ LICENSE | 21 ++ README.md | 111 ++++++++ Samples/MultiTarget/Class1.cs | 14 + Samples/MultiTarget/MultiTarget.csproj | 13 + Samples/NetFramework/Class1.cs | 14 + Samples/NetFramework/NetFramework.csproj | 63 +++++ .../NetFramework/Properties/AssemblyInfo.cs | 36 +++ Samples/NetFramework/packages.config | 4 + Samples/SingleTarget/Class1.cs | 14 + Samples/SingleTarget/SingleTarget.csproj | 11 + Version.props | 8 + appveyor.yml | 33 +++ build.ps1 | 261 ++++++++++++++++++ 31 files changed, 1772 insertions(+) create mode 100644 .gitignore create mode 100644 DllExports.MSBuild/DllExports.MSBuild.csproj create mode 100644 DllExports.MSBuild/GenerateDllExports.cs create mode 100644 DllExports.MSBuild/IsolatedTaskRunner.cs create mode 100644 DllExports.MSBuild/NetCoreAssemblyLoadContext.cs create mode 100644 DllExports.MSBuild/NetFrameworkAssemblyLoadContext.cs create mode 100644 DllExports.MSBuild/RemoteHelper.cs create mode 100644 DllExports.MSBuild/build/DllExports.props create mode 100644 DllExports.MSBuild/build/DllExports.targets create mode 100644 DllExports.Tests/AssertEx.cs create mode 100644 DllExports.Tests/DllExports.Tests.csproj create mode 100644 DllExports.Tests/ExporterTests.cs create mode 100644 DllExports.Tests/MockBuildEngine.cs create mode 100644 DllExports.sln create mode 100644 DllExports/DllExportAttribute.cs create mode 100644 DllExports/DllExports.csproj create mode 100644 DllExports/ExportOptions.cs create mode 100644 DllExports/Exporter.cs create mode 100644 LICENSE create mode 100644 README.md create mode 100644 Samples/MultiTarget/Class1.cs create mode 100644 Samples/MultiTarget/MultiTarget.csproj create mode 100644 Samples/NetFramework/Class1.cs create mode 100644 Samples/NetFramework/NetFramework.csproj create mode 100644 Samples/NetFramework/Properties/AssemblyInfo.cs create mode 100644 Samples/NetFramework/packages.config create mode 100644 Samples/SingleTarget/Class1.cs create mode 100644 Samples/SingleTarget/SingleTarget.csproj create mode 100644 Version.props create mode 100644 appveyor.yml create mode 100644 build.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f2e4027 --- /dev/null +++ b/.gitignore @@ -0,0 +1,49 @@ +################# +# Visual Studio # +################# + +# Build results +[Bb]in/ +[Oo]bj/ + +# NuGet Packages Directory +packages/ + +# NuGet Packages +*.nupkg +*.snupkg +*.zip + +# MSBuild logs +*.binlog + +# MSTest test Results +[Tt]est[Rr]esult*/ + +# ReSharper settings +*.DotSettings + +# User-specific files +.vs/ +*.user + +#################### +# Windows detritus # +#################### + +# Windows image file caches +Thumbs.db +ehthumbs.db + +# Folder config file +Desktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +################## +# MacOS detritus # +################## + +# Folder config file +.DS_Store \ No newline at end of file diff --git a/DllExports.MSBuild/DllExports.MSBuild.csproj b/DllExports.MSBuild/DllExports.MSBuild.csproj new file mode 100644 index 0000000..8773856 --- /dev/null +++ b/DllExports.MSBuild/DllExports.MSBuild.csproj @@ -0,0 +1,65 @@ + + + + + + net472;netstandard2.0;net5.0 + true + + DllExports + DllExports + lordmilko + Unmanaged Exports for legacy/SDK style projects + false + (c) 2023 lordmilko. All rights reserved. + + DllExports provides unmanaged exports for both legacy and SDK style projects. + +Unlike other libraries that rely on a wacky series of external dependencies, DllExports has everything it needs to do its job built in. + +DllExports is entirely driven by its MSBuild task, and provides a number of knobs you can adjust to customize the resulting assemblies, +including converting AnyCPU assemblies into both x86 and x64 outputs. + +Note that when using IDA Pro, load the file as "Portable executable for 80386 (PE)" instead of "Microsoft.NET assembly" in order to see the exports. + +In order to be able to debug your exports in Visual Studio you must be targeting .NET Framework. .NET Standard exports work but you can't debug them. +.NET Core applications can't truly have unmanaged exports as you can't use mscoree to load their runtime. Consider using a library such as DNNE for proper .NET Core support. + + + + + + + + + + + + + + + + true + build + + + + + + + + $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage + + + + + <_ReferenceCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" /> + + + + + + + + + \ No newline at end of file diff --git a/DllExports.MSBuild/GenerateDllExports.cs b/DllExports.MSBuild/GenerateDllExports.cs new file mode 100644 index 0000000..5d7804a --- /dev/null +++ b/DllExports.MSBuild/GenerateDllExports.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Linq; +using System.Reflection; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; + +namespace DllExports.MSBuild +{ + public class GenerateDllExports : Task + { + private ExportOptions options = new ExportOptions(); + + public bool Enabled + { + get => options.Enabled; + set => options.Enabled = value; + } + + public string InputFile + { + get => options.InputFile; + set => options.InputFile = value; + } + + public string OutputFile + { + get => options.OutputFile; + set => options.OutputFile = value; + } + + public string[] Architectures + { + get => options.Architectures; + set => options.Architectures = value; + } + + public string ArchitectureNameFormat + { + get => options.ArchitectureNameFormat; + set => options.ArchitectureNameFormat = value; + } + + public bool RemoveInputFile + { + get => options.RemoveInputFile; + set => options.RemoveInputFile = value; + } + + public override bool Execute() + { + if (!Enabled) + { + Log.LogMessage($"DllExports{nameof(Enabled)} was false. Nothing to do"); + return true; + } + + if (string.IsNullOrWhiteSpace(InputFile)) + { + Log.LogError($"DllExports{nameof(InputFile)} must be specified"); + return false; + } + + Log.LogMessage(MessageImportance.Normal, $"Processing file '{InputFile}'"); + + if (string.IsNullOrWhiteSpace(OutputFile)) + { + Log.LogError($"DllExports{nameof(OutputFile)} must be specified"); + return false; + } + + if (options.Architectures != null) + { + if (string.IsNullOrWhiteSpace(options.ArchitectureNameFormat)) + { + Log.LogError($"DllExports{nameof(ArchitectureNameFormat)} must be specified when DllExports{nameof(Architectures)} is specified"); + return false; + } + + var validArchs = new[] + { + "I386", + "AMD64" + }; + + foreach (var arch in options.Architectures) + { + if (!validArchs.Contains(arch, StringComparer.OrdinalIgnoreCase)) + { + Log.LogError($"Invalid architecture '{arch}' specified. Valid architectures: {string.Join(", ", validArchs)}"); + return false; + } + } + } + + var outputs = options.CalculateOutputFiles(); + + if (options.RemoveInputFile && outputs.Any(o => StringComparer.OrdinalIgnoreCase.Equals(o.Path, options.InputFile))) + { + Log.LogError($"Cannot set option DllExports{nameof(RemoveInputFile)} to true when inputs and outputs are the same file"); + return false; + } + + var taskRunner = new IsolatedTaskRunner(); + try + { + taskRunner.Execute(options); + } + catch (TargetInvocationException ex) + { + Log.LogErrorFromException(ex.InnerException); + return false; + } + catch (Exception ex) + { + Log.LogErrorFromException(ex); + return false; + } + + if (options.RemoveInputFile) + File.Delete(options.InputFile); + + var directory = Path.GetDirectoryName(options.InputFile); + var dllExportsDll = Path.Combine(directory, "DllExports.dll"); + + if (File.Exists(dllExportsDll)) + File.Delete(dllExportsDll); + + Log.LogMessage(MessageImportance.High, string.Empty); + + foreach (var output in outputs) + Log.LogMessage(MessageImportance.High, $"DllExports ({output.Name}) -> {output.Path}"); + + return true; + } + } +} diff --git a/DllExports.MSBuild/IsolatedTaskRunner.cs b/DllExports.MSBuild/IsolatedTaskRunner.cs new file mode 100644 index 0000000..c567e04 --- /dev/null +++ b/DllExports.MSBuild/IsolatedTaskRunner.cs @@ -0,0 +1,64 @@ +using System.IO; +using System.Reflection; +#if NET +using System.Runtime.Loader; +#endif + +namespace DllExports.MSBuild +{ + class IsolatedTaskRunner + { + public void Execute(ExportOptions options) + { +#if NET + var context = new NetCoreAssemblyLoadContext(); +#else + var context = new NetFrameworkAssemblyLoadContext(); +#endif + + try + { + var assemblyPath = GetType().Assembly.Location; + var assemblyDirectory = Path.GetDirectoryName(assemblyPath); + var dllExportsPath = Path.Combine(assemblyDirectory, "DllExports.dll"); + + if (!File.Exists(dllExportsPath)) + throw new FileNotFoundException($"Could not find '{dllExportsPath}'", dllExportsPath); + + context.SetDllDirectory(assemblyDirectory); + + using (var fileStream = File.OpenRead(dllExportsPath)) + { + try + { + var assembly = context.LoadAssembly(fileStream); + + context.Export(assembly, options); + } + finally + { + //I'm not sure if its such a great idea to be closing the file AFTER we've already unloaded, + //so we kick off an unload here + context.Unload(); + } + } + } + finally + { + //If we crashed before we attempted to unload above, we need to unload here + context.Unload(); + } + } + } + + interface IAssemblyLoadContext + { + Assembly LoadAssembly(Stream stream); + + void SetDllDirectory(string path); + + void Export(Assembly assembly, ExportOptions options); + + void Unload(); + } +} \ No newline at end of file diff --git a/DllExports.MSBuild/NetCoreAssemblyLoadContext.cs b/DllExports.MSBuild/NetCoreAssemblyLoadContext.cs new file mode 100644 index 0000000..5617dc1 --- /dev/null +++ b/DllExports.MSBuild/NetCoreAssemblyLoadContext.cs @@ -0,0 +1,71 @@ +#if NET +using System; +using System.Linq; +using System.Runtime.Loader; +using System.IO; +using System.Reflection; + +namespace DllExports.MSBuild +{ + class AssemblyContext : AssemblyLoadContext + { + public AssemblyContext() : base(true) + { + } + } + + class NetCoreAssemblyLoadContext : IAssemblyLoadContext + { + private AssemblyContext loader = new AssemblyContext(); + private bool unloaded; + + public NetCoreAssemblyLoadContext() + { + } + + public Assembly LoadAssembly(Stream stream) => + loader.LoadFromStream(stream); + + public void SetDllDirectory(string path) + { + var files = Directory.EnumerateFiles(path, "*.dll").ToArray(); + + loader.Resolving += (ctx, name) => + { + foreach (var file in files) + { + if (StringComparer.OrdinalIgnoreCase.Equals(Path.GetFileNameWithoutExtension(file), name.Name)) + return ctx.LoadFromAssemblyPath(file); + } + + return null; + }; + } + + public void Export(Assembly assembly, ExportOptions options) + { + var exporterType = assembly.GetType("DllExports.Exporter"); + var exportMethod = exporterType.GetMethod("Export"); + + exportMethod.Invoke(null, new object[] + { + options.Enabled, + options.InputFile, + options.OutputFile, + options.Architectures, + options.ArchitectureNameFormat, + options.RemoveInputFile + }); + } + + public void Unload() + { + if (unloaded) + return; + + loader.Unload(); + unloaded = true; + } + } +} +#endif \ No newline at end of file diff --git a/DllExports.MSBuild/NetFrameworkAssemblyLoadContext.cs b/DllExports.MSBuild/NetFrameworkAssemblyLoadContext.cs new file mode 100644 index 0000000..771dd24 --- /dev/null +++ b/DllExports.MSBuild/NetFrameworkAssemblyLoadContext.cs @@ -0,0 +1,81 @@ +#if !NET +using System; +using System.IO; +using System.Reflection; + +namespace DllExports.MSBuild +{ + internal class NetFrameworkAssemblyLoadContext : MarshalByRefObject, IAssemblyLoadContext + { + private AppDomain appDomain; +#pragma warning disable 649 + private object helper; +#pragma warning restore 649 + private bool unloaded; + + public NetFrameworkAssemblyLoadContext() + { + appDomain = AppDomain.CreateDomain("DllExportAppDomain"); + +#if NETFRAMEWORK + helper = appDomain.CreateInstanceFromAndUnwrap( + GetType().Assembly.Location, + typeof(RemoteHelper).FullName, + false, + BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, + null, + new object[0], + null, + null + ); +#else + throw new NotSupportedException(); +#endif + } + + public Assembly LoadAssembly(Stream stream) + { + //Attempting to load DllExports.dll inside of MSBuild will cause it to try and look the DLL up + //in the Visual Studio installation directory. Our assembly resolver is never called. As such, + //we don't even bother trying to pre-emptively load DllExports.dll. We don't need to anyway, + //since it will "just work" when we dispatch to it via RemoteHelper + return null; + } + + public void SetDllDirectory(string path) + { + //I don't know why MSBuild gets upset when I try and cast my transparent proxy to RemoteHelper, + //and I don't really care. So we'll use type object instead and abuse the Equals method to dispatch + //everything + helper.Equals(path); + } + + public void Export(Assembly assembly, ExportOptions options) + { + //I don't know why MSBuild gets upset when I try and cast my transparent proxy to RemoteHelper, + //and I don't really care. So we'll use type object instead and abuse the Equals method to dispatch + //everything + helper.Equals( + new object[] + { + options.Enabled, + options.InputFile, + options.OutputFile, + options.Architectures, + options.ArchitectureNameFormat, + options.RemoveInputFile + } + ); + } + + public void Unload() + { + if (unloaded) + return; + + AppDomain.Unload(appDomain); + unloaded = true; + } + } +} +#endif \ No newline at end of file diff --git a/DllExports.MSBuild/RemoteHelper.cs b/DllExports.MSBuild/RemoteHelper.cs new file mode 100644 index 0000000..473197b --- /dev/null +++ b/DllExports.MSBuild/RemoteHelper.cs @@ -0,0 +1,66 @@ +#if NETFRAMEWORK +using System; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace DllExports.MSBuild +{ + public class RemoteHelper : MarshalByRefObject + { + private string[] files; + + private Assembly OnAssemblyResolve(object sender, ResolveEventArgs e) + { + var name = e.Name; + + if (e.Name != null && e.Name.Contains(",")) + name = name.Substring(0, name.IndexOf(',')); + + foreach (var file in files) + { + if (StringComparer.OrdinalIgnoreCase.Equals(Path.GetFileNameWithoutExtension(file), name)) + return Assembly.LoadFile(file); + } + + return null; + } + + public override bool Equals(object obj) + { + if (obj is string) + { + files = Directory.EnumerateFiles(obj.ToString(), "*.dll").ToArray(); + + AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve; + } + else + { + //For some reason when we use reflection to invoke the method in the remote AppDomain we're still ending up back + //in our original domain; as such, we'll force ourselves to run in another domain by creating an object (RemoteHelper) + //in the remote AppDomain and doing everything through here + + var arr = (object[])obj; + + Exporter.Export( + (bool) arr[0], + (string) arr[1], + (string) arr[2], + (string[]) arr[3], + (string) arr[4], + (bool) arr[5] + ); + } + + return true; + } + + public override int GetHashCode() + { + return base.GetHashCode(); + } + + public override object InitializeLifetimeService() => null; + } +} +#endif \ No newline at end of file diff --git a/DllExports.MSBuild/build/DllExports.props b/DllExports.MSBuild/build/DllExports.props new file mode 100644 index 0000000..943e470 --- /dev/null +++ b/DllExports.MSBuild/build/DllExports.props @@ -0,0 +1,11 @@ + + + + net5.0 + net472 + + $(MSBuildThisFileDirectory)..\tasks\$(TaskFolder)\DllExports.MSBuild.dll + + + + \ No newline at end of file diff --git a/DllExports.MSBuild/build/DllExports.targets b/DllExports.MSBuild/build/DllExports.targets new file mode 100644 index 0000000..287837c --- /dev/null +++ b/DllExports.MSBuild/build/DllExports.targets @@ -0,0 +1,36 @@ + + + + + true + + + $(TargetPath) + + + $(DllExportsInputFile) + + + + + + {name}.{arch} + + + false + + + + + \ No newline at end of file diff --git a/DllExports.Tests/AssertEx.cs b/DllExports.Tests/AssertEx.cs new file mode 100644 index 0000000..7dc34e9 --- /dev/null +++ b/DllExports.Tests/AssertEx.cs @@ -0,0 +1,27 @@ +using System; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DllExports.Tests +{ + public static class AssertEx + { + public static void Throws(Action action, string message, bool checkMessage = true) where T : Exception + { + try + { + action(); + + Assert.Fail($"Expected an assertion of type {typeof(T)} to be thrown, however no exception occurred"); + } + catch (T ex) + { + if (checkMessage) + Assert.IsTrue(ex.Message.Contains(message), $"Exception message '{ex.Message}' did not contain string '{message}'"); + } + catch (Exception ex) when (!(ex is AssertFailedException)) + { + throw; + } + } + } +} \ No newline at end of file diff --git a/DllExports.Tests/DllExports.Tests.csproj b/DllExports.Tests/DllExports.Tests.csproj new file mode 100644 index 0000000..3fecbdf --- /dev/null +++ b/DllExports.Tests/DllExports.Tests.csproj @@ -0,0 +1,23 @@ + + + + net5.0;net472 + + false + + + + + + + + + + + + + + + + + diff --git a/DllExports.Tests/ExporterTests.cs b/DllExports.Tests/ExporterTests.cs new file mode 100644 index 0000000..359ab35 --- /dev/null +++ b/DllExports.Tests/ExporterTests.cs @@ -0,0 +1,158 @@ +using System; +using System.IO; +using DllExports.MSBuild; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace DllExports.Tests +{ + [TestClass] + public class ExporterTests + { + [TestMethod] + public void Exporter_Success() + { + TestExporter(null); + } + + [TestMethod] + public void Exporter_Enabled_False() + { + TestExporter(t => t.Enabled = false); + } + + [TestMethod] + public void Exporter_Architectures_DefaultNameFormat() + { + TestExporter(t => + { + t.Architectures = new[] {"i386"}; + t.ArchitectureNameFormat = "{name}.{arch}"; + }); + } + + [TestMethod] + public void Exporter_Architectures_CustomNameFormat() + { + TestExporter(t => + { + t.Architectures = new[] { "i386" }; + t.ArchitectureNameFormat = "{name}.foo.{arch}"; + }); + } + + [TestMethod] + public void Exporter_InputFile_Empty_Throws() + { + var task = new GenerateDllExports + { + Enabled = true, + InputFile = null + }; + + AssertEx.Throws( + () => task.Execute(), + "DllExportsInputFile must be specified" + ); + } + + [TestMethod] + public void Exporter_OutputFile_Empty_Throws() + { + var task = new GenerateDllExports + { + Enabled = true, + InputFile = "foo" + }; + + task.BuildEngine = new MockBuildEngine(); + + AssertEx.Throws( + () => task.Execute(), + "DllExportsOutputFile must be specified" + ); + } + + [TestMethod] + public void Exporter_ArchitectureNameFormat_WithArchitectures_Empty_Throws() + { + AssertEx.Throws( + () => + { + TestExporter(t => + { + t.Architectures = new[] { "i386" }; + t.ArchitectureNameFormat = null; + }); + }, + "DllExportsArchitectureNameFormat must be specified when DllExportsArchitectures is specified" + ); + } + + [TestMethod] + public void Exporter_RemoveInputFile_SameInputAndOutput_Throws() + { + AssertEx.Throws( + () => TestExporter(t => t.RemoveInputFile = true), + "Cannot set option DllExportsRemoveInputFile to true when inputs and outputs are the same file" + ); + } + + [TestMethod] + public void Exporter_RemoveInputFile_ArchitectureSpecificExports() + { + TestExporter(t => + { + t.Architectures = new[] { "i386" }; + t.ArchitectureNameFormat = "{name}.{arch}"; + t.RemoveInputFile = true; + }); + } + + private void TestExporter(Action configure) + { + var assemblyPath = GetType().Assembly.Location; + var directory = Path.GetDirectoryName(assemblyPath); + + var targetFramework = "netstandard2.0"; + +#if DEBUG + var configuration = "Debug"; +#else + var configuration = "Release"; +#endif + + var solution = Path.GetFullPath(Path.Combine(directory, "..", "..", "..", "..")); + + var testFile = Path.Combine(solution, "Samples", "SingleTarget", "bin", configuration, targetFramework, "SingleTarget.dll"); + + if (!File.Exists(testFile)) + throw new InvalidOperationException($"Test File '{testFile}' was not found. Test project must be compiled prior to running tests"); + + var tmpFile = Path.GetTempFileName(); + tmpFile = Path.ChangeExtension(tmpFile, ".dll"); + + try + { + File.Copy(testFile, tmpFile, true); + + var task = new GenerateDllExports + { + Enabled = true, + InputFile = tmpFile, + OutputFile = tmpFile, + + BuildEngine = new MockBuildEngine() + }; + + configure?.Invoke(task); + + task.Execute(); + } + finally + { + if (File.Exists(tmpFile)) + File.Delete(tmpFile); + } + } + } +} diff --git a/DllExports.Tests/MockBuildEngine.cs b/DllExports.Tests/MockBuildEngine.cs new file mode 100644 index 0000000..cc6c7f7 --- /dev/null +++ b/DllExports.Tests/MockBuildEngine.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections; +using Microsoft.Build.Framework; + +namespace DllExports.Tests +{ + class MockBuildEngine : IBuildEngine + { + public void LogErrorEvent(BuildErrorEventArgs e) + { + throw new InvalidOperationException(e.Message); + } + + public void LogWarningEvent(BuildWarningEventArgs e) + { + } + + public void LogMessageEvent(BuildMessageEventArgs e) + { + } + + public void LogCustomEvent(CustomBuildEventArgs e) + { + } + + public bool BuildProjectFile(string projectFileName, string[] targetNames, IDictionary globalProperties, + IDictionary targetOutputs) + { + throw new NotImplementedException(); + } + + public bool ContinueOnError { get; } + public int LineNumberOfTaskNode { get; } + public int ColumnNumberOfTaskNode { get; } + public string ProjectFileOfTaskNode { get; } + } +} \ No newline at end of file diff --git a/DllExports.sln b/DllExports.sln new file mode 100644 index 0000000..5ae296f --- /dev/null +++ b/DllExports.sln @@ -0,0 +1,37 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.32126.315 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DllExports", "DllExports\DllExports.csproj", "{ABBFD833-DD5F-40EE-B2EA-9A8E9641283A}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DllExports.MSBuild", "DllExports.MSBuild\DllExports.MSBuild.csproj", "{0F2800DF-65E6-47F8-949F-E401881D5970}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DllExports.Tests", "DllExports.Tests\DllExports.Tests.csproj", "{D8920F8A-90C4-4D1E-9750-8ED1D79086C9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {ABBFD833-DD5F-40EE-B2EA-9A8E9641283A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ABBFD833-DD5F-40EE-B2EA-9A8E9641283A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ABBFD833-DD5F-40EE-B2EA-9A8E9641283A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ABBFD833-DD5F-40EE-B2EA-9A8E9641283A}.Release|Any CPU.Build.0 = Release|Any CPU + {0F2800DF-65E6-47F8-949F-E401881D5970}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F2800DF-65E6-47F8-949F-E401881D5970}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F2800DF-65E6-47F8-949F-E401881D5970}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F2800DF-65E6-47F8-949F-E401881D5970}.Release|Any CPU.Build.0 = Release|Any CPU + {D8920F8A-90C4-4D1E-9750-8ED1D79086C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D8920F8A-90C4-4D1E-9750-8ED1D79086C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D8920F8A-90C4-4D1E-9750-8ED1D79086C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D8920F8A-90C4-4D1E-9750-8ED1D79086C9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {787DE98D-5953-4B1F-9291-5EFDD6E38922} + EndGlobalSection +EndGlobal diff --git a/DllExports/DllExportAttribute.cs b/DllExports/DllExportAttribute.cs new file mode 100644 index 0000000..2b05083 --- /dev/null +++ b/DllExports/DllExportAttribute.cs @@ -0,0 +1,30 @@ +using System; +using System.Runtime.InteropServices; + +namespace DllExports +{ + [AttributeUsage(AttributeTargets.Method)] + public class DllExportAttribute : Attribute + { + public string ExportName { get; } + + public CallingConvention CallingConvention { get; } + + public DllExportAttribute() + { + CallingConvention = CallingConvention.StdCall; + } + + public DllExportAttribute(string exportName) + { + ExportName = exportName; + CallingConvention = CallingConvention.StdCall; + } + + public DllExportAttribute(string exportName, CallingConvention callingConvention) + { + ExportName = exportName; + CallingConvention = callingConvention; + } + } +} \ No newline at end of file diff --git a/DllExports/DllExports.csproj b/DllExports/DllExports.csproj new file mode 100644 index 0000000..f268337 --- /dev/null +++ b/DllExports/DllExports.csproj @@ -0,0 +1,17 @@ + + + + + + netstandard2.0;net5.0 + true + false + + DllExports.Internal + + + + + + + \ No newline at end of file diff --git a/DllExports/ExportOptions.cs b/DllExports/ExportOptions.cs new file mode 100644 index 0000000..32d0e76 --- /dev/null +++ b/DllExports/ExportOptions.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace DllExports +{ + internal class ExportOptions + { + public bool Enabled { get; set; } + + public string InputFile { get; set; } + + public string OutputFile { get; set; } + + public string[] Architectures { get; set; } + + public string ArchitectureNameFormat { get; set; } + + public bool RemoveInputFile { get; set; } + + internal (string Path, string Name, bool? Is32Bit)[] CalculateOutputFiles() + { + var outputFile = GetOutputFileName(); + + if (Architectures == null || Architectures.Length == 0) + return new (string Path, string Name, bool? Is32Bit)[] { (outputFile, "Default", null) }; + + var results = new List<(string Path, string Name, bool? Is32Bit)>(); + + foreach (var arch in Architectures) + { + var outputDir = Path.GetDirectoryName(outputFile); + var baseName = Path.GetFileNameWithoutExtension(outputFile); + var ext = Path.GetExtension(outputFile); + + string displayArch; + bool is32Bit; + + switch (arch.ToLower()) + { + case "i386": + displayArch = "x86"; + is32Bit = true; + break; + + case "amd64": + displayArch = "x64"; + is32Bit = false; + break; + + default: + throw new NotSupportedException($"Architecture '{arch}' is not supported"); + } + + var newName = Path.Combine( + outputDir, + ArchitectureNameFormat.Replace("{name}", baseName).Replace("{arch}", displayArch) + ext + ); + + results.Add((newName, displayArch, is32Bit)); + } + + return results.ToArray(); + } + + private string GetOutputFileName() + { + var directory = Path.GetDirectoryName(OutputFile); + + var outputFile = OutputFile; + + if (string.IsNullOrWhiteSpace(directory)) + { + directory = Path.GetDirectoryName(InputFile); + var fileName = Path.GetFileName(OutputFile); + outputFile = Path.Combine(directory, fileName); + } + + if (string.IsNullOrWhiteSpace(Path.GetExtension(outputFile))) + outputFile += Path.GetExtension(InputFile); + + return outputFile; + } + } +} \ No newline at end of file diff --git a/DllExports/Exporter.cs b/DllExports/Exporter.cs new file mode 100644 index 0000000..ac0bfb4 --- /dev/null +++ b/DllExports/Exporter.cs @@ -0,0 +1,175 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using dnlib.DotNet; +using dnlib.DotNet.MD; +using dnlib.DotNet.Writer; +using dnlib.PE; +using CallingConvention = System.Runtime.InteropServices.CallingConvention; + +[assembly: InternalsVisibleTo("DllExports.MSBuild")] +[assembly: InternalsVisibleTo("DllExports.Tests")] + +namespace DllExports +{ + internal class Exporter + { + public static void Export( + bool enabled, + string inputFile, + string outputFile, + string[] architectures, + string architectureNameFormat, + bool removeInputFile) + { + var options = new ExportOptions + { + Enabled = enabled, + InputFile = inputFile, + OutputFile = outputFile, + Architectures = architectures, + ArchitectureNameFormat = architectureNameFormat, + RemoveInputFile = removeInputFile + }; + + var moduleContext = ModuleDef.CreateModuleContext(); + + //If we simply create a ModuleDefMD from a memory stream, we're not going to know our original filename + //and be able to find our PDB. If we simply load the module from the filename, our file will be in use + //if we attempt to overwrite it. As such, we tap into the underlying PEImage type, which lets us read the + //entire byte array of the file AND specify the input filename + var bytes = File.ReadAllBytes(options.InputFile); + var peImage = new PEImage(bytes, options.InputFile); + + var module = ModuleDefMD.Load(peImage, moduleContext); + + var exportedMethods = module.Types.SelectMany(t => t.Methods) + .Where(m => m.IsStatic) + .Select(m => new + { + Method = m, + Attrib = m.CustomAttributes.Find(typeof(DllExportAttribute).FullName) + }) + .Where(v => v.Attrib != null) + .ToArray(); + + foreach (var item in exportedMethods) + { + var exportedMethod = item.Method; + + string exportName; + string callingConvention = null; + + if (item.Attrib.ConstructorArguments.Count == 0) + exportName = exportedMethod.Name; + else + { + if (item.Attrib.ConstructorArguments.Count == 2) + { + var conv = (CallingConvention)item.Attrib.ConstructorArguments[1].Value; + + switch (conv) + { + case CallingConvention.StdCall: + callingConvention = typeof(CallConvStdcall).Name; + break; + + case CallingConvention.Cdecl: + callingConvention = typeof(CallConvCdecl).Name; + break; + + case CallingConvention.FastCall: + callingConvention = typeof(CallConvFastcall).Name; + break; + + case CallingConvention.ThisCall: + callingConvention = typeof(CallConvThiscall).Name; + break; + + case CallingConvention.Winapi: + callingConvention = typeof(CallConvStdcall).Name; + break; + + default: + throw new NotSupportedException($"Calling convention {conv} is not supported"); + } + } + + exportName = item.Attrib.ConstructorArguments[0].Value?.ToString(); + } + + if (callingConvention == null) + callingConvention = typeof(CallConvStdcall).Name; + + exportedMethod.ExportInfo = new MethodExportInfo(exportName); + exportedMethod.IsUnmanagedExport = true; + + exportedMethod.MethodSig.RetType = new CModOptSig( + module.CorLibTypes.GetTypeRef("System.Runtime.CompilerServices", callingConvention), + exportedMethod.MethodSig.RetType + ); + + exportedMethod.CustomAttributes.Remove(item.Attrib); + } + + module.IsILOnly = false; + + ClearEditAndContinue(module); + + var outputFiles = options.CalculateOutputFiles(); + + foreach (var output in outputFiles) + { + var moduleOptions = GetModuleOptions(module, output.Is32Bit); + + var dir = Path.GetDirectoryName(output.Path); + + if (!Directory.Exists(dir)) + Directory.CreateDirectory(dir); + + moduleOptions.WritePdb = true; + + module.Write(options.OutputFile, moduleOptions); + } + } + + private static ModuleWriterOptions GetModuleOptions(ModuleDefMD module, bool? is32Bit) + { + var moduleOptions = new ModuleWriterOptions(module); + + moduleOptions.Cor20HeaderOptions.Flags &= ~(ComImageFlags.ILOnly); + + if (is32Bit != null) + { + moduleOptions.PEHeadersOptions.Machine = is32Bit.Value ? Machine.I386 : Machine.AMD64; + + if (is32Bit.Value) + { + moduleOptions.Cor20HeaderOptions.Flags |= ComImageFlags.Bit32Required; + moduleOptions.Cor20HeaderOptions.Flags &= ~(ComImageFlags.Bit32Preferred); + } + } + + return moduleOptions; + } + + private static void ClearEditAndContinue(ModuleDefMD module) + { + var debuggableAttrib = module.Assembly.CustomAttributes.Find("System.Diagnostics.DebuggableAttribute"); + + if (debuggableAttrib != null && debuggableAttrib.ConstructorArguments.Count == 1) + { + var arg = debuggableAttrib.ConstructorArguments[0]; + + // VS' debugger crashes if value == 0x107, so clear EnC bit + if (arg.Type.FullName == "System.Diagnostics.DebuggableAttribute/DebuggingModes" && arg.Value is int value && value == 0x107) + { + arg.Value = value & ~(int)DebuggableAttribute.DebuggingModes.EnableEditAndContinue; + debuggableAttrib.ConstructorArguments[0] = arg; + } + } + } + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1859daf --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 lordmilko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..678343a --- /dev/null +++ b/README.md @@ -0,0 +1,111 @@ +# DllExports + +[![Appveyor status](https://ci.appveyor.com/api/projects/status/mgsa6414kmv4aoko?svg=true)](https://ci.appveyor.com/project/lordmilko/dllexports) +[![NuGet](https://img.shields.io/nuget/v/DllExports.svg)](https://www.nuget.org/packages/DllExports/) + +*All I want in life is a package to enable unmanaged exports via a simple MSBuild task in my .NET application* + +There are several packages out there that attempt to facilitate enabling unmanaged exports in .NET applications. A big challenge with existing solutions however is that either: + +* They don't work +* They require a bunch of wacky external dependencies +* They won't work because they require a bunch of wacky external dependencies + +DllExports aims to be a simple .NET package that: + +1. Works +2. Doesn't require any external dependencies + +Thus hopefully ensuring it continues to work into the future. + +**In order to be able to debug your exports in Visual Studio you must be targeting .NET Framework. .NET Standard exports work but you can't debug them +(presumably because it expected the .NET Core runtime to be loaded but .NET Framework was loaded instead). .NET Core applications can't truly have unmanaged +exports as you can't use mscoree to load their runtime. Consider using a library such as [DNNE](https://github.com/AaronRobinsonMSFT/DNNE) for proper .NET Core support +(however this will require C++ tooling to be properly installed).** + +Note that when using IDA Pro, load the file as `Portable executable for 80386 (PE)` instead of `Microsoft.NET assembly` in order to see the exports. + +See [Tips](#tips) for some important gotchas to be aware of. + +## Usage + +To declare an unmanaged export, simply decorate a static method with `DllExportAttribute` + +```c# +using DllExports; + +[DllExport] +public static void Foo() +{ +} +``` +You may optionally also specify the name and calling convention to use for the unmanaged export. If no calling convention is specified, by default `stdcall` will be used. + +DllExports provides a number of knobs you can use to adjust how your input file will be processed. + +| Property | Default Value | Description | +| -------------------------------- | ------------------------ | ------------------------------------------------------------------------- | +| DllExportsEnabled | `true` | Whether DllExports should process unmanaged exports upon building | +| DllExportsInputFile | `$(TargetPath)` | The file DllExports should process unmanaged exports for | +| DllExportsOutputFile | `$(DllExportsInputFile)` | The file to save the modified file as | +| DllExportsArchitectures | | Architectures to generate DllExports for. e.g. `i386;AMD64`. Each architecture will get its own file. If no architecture is specified, the architecture is not modified | +| DllExportsArchitectureNameFormat | `{name}.{arch}` | The name format used when processing `DllExportsArchitectures`. Resulting filename will be `DllExportsOutputFile` directory + `DllExportsArchitectureNameFormat` + file extension | +| DllExportsRemoveInputFile | `false` | Whether to remove `DllExportsInputFile` upon generating unmanaged exports | + +DllExports currently only supports generating unmanaged exports for i386 and AMD64. + +When you compile a library as AnyCPU, implicitly it is actually either i386 or AMD64, and will only be loaded properly in an application with a matching architecture. + +## Legacy Projects + +Historically, packages have installed MSBuild props and targets via a PowerShell init script embedded within the NuGet package. Such scripts do not work, and are +essentially unnecessary when it comes to SDK style projects. As SDK style projects are a lot easier to manage than legacy style projects, it makes sense to use +SDK style projects even if you are building applications against .NET Framework. As such, DllExports does not provide a script for automatically updating your project file +for seamless support with legacy style projects. You can easily inject the required changes yourself however, as follows: + +1. Insert the `props` import after all other props imports at the top of the file + +```xml + +``` +2. Add a package reference, with `Private = False` so DllExports does not get emitted to your output directory + +```xml + + ..\..\packages\DllExports.0.1.0\lib\netstandard2.0\DllExports.dll + False + +``` +3. Import the `targets` at the end of the file and add a `Target` to warn when NuGet packages have not been restored + +```xml + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + +``` + +Adjust the version number in the above snippets as necessary. The [NetFramework](https://github.com/lordmilko/DllExports/blob/master/Samples/NetFramework/NetFramework.csproj) sample demonstrates how your file should look like. + +## Tips + +* Don't use types types external to your assembly or the CLR in the method signature of your exports. e.g. do not use the `HRESULT` type from [ClrDebug](https://github.com/lordmilko/ClrDebug). The runtime is not in a position to load +external assemblies when your export is called. You can however use types defined in the same assembly that your export is defined in + * Once an external assembly has been loaded, it is safe to use types in external external assemblies in subsequently called exports +* You can force architecture specific files to be placed in an architecture specific subdirectory by setting `DllExportsArchitectureNameFormat` to something like `{arch}\{name}.{arch}` i.e. `Foo.dll` compiled for AMD64 will go to `x64\Foo.x64.dll` +* When multi-targeting, you can conditionally generate unmanaged exports for compatible assemblies as follows + + ```xml + netstandard2.0;net472 + + false + true + ``` +* When consuming third party libraries in your unmanaged export, watch out for assembly resolution issues! If you exported assembly is loaded into some other application, +when you attempt to reference a type in a third party library, the CLR is going to look in the directory of *that application* - **not** the directory that your assembly and all +its dependencies are in. Consider setting `AppDomain.CurrentDomain.AssemblyResolve` and/or pre-emptively loading your assemblies in the first export accessed via `Assembly.LoadFrom` + * The CLR will only attempt to load an assembly when a type within it is referenced within a given method. If your program relies on an outer method doing assembly resolution prior + to calling an inner method, consider decorating the inner method with `[MethodImpl(MethodImplOptions.NoInlining)]` \ No newline at end of file diff --git a/Samples/MultiTarget/Class1.cs b/Samples/MultiTarget/Class1.cs new file mode 100644 index 0000000..1b6e465 --- /dev/null +++ b/Samples/MultiTarget/Class1.cs @@ -0,0 +1,14 @@ +using System; +using System.Runtime.InteropServices; +using DllExports; + +namespace MultiTarget +{ + public class Class1 + { + [DllExport("MyExport")] + public static void InternalName() + { + } + } +} \ No newline at end of file diff --git a/Samples/MultiTarget/MultiTarget.csproj b/Samples/MultiTarget/MultiTarget.csproj new file mode 100644 index 0000000..17171a0 --- /dev/null +++ b/Samples/MultiTarget/MultiTarget.csproj @@ -0,0 +1,13 @@ + + + + netstandard2.0;net5.0 + + false + + + + + + + diff --git a/Samples/NetFramework/Class1.cs b/Samples/NetFramework/Class1.cs new file mode 100644 index 0000000..335fc56 --- /dev/null +++ b/Samples/NetFramework/Class1.cs @@ -0,0 +1,14 @@ +using System; +using System.Runtime.InteropServices; +using DllExports; + +namespace NetFramework +{ + public class Class1 + { + [DllExport("MyExport", CallingConvention.StdCall)] + public static void InternalName() + { + } + } +} \ No newline at end of file diff --git a/Samples/NetFramework/NetFramework.csproj b/Samples/NetFramework/NetFramework.csproj new file mode 100644 index 0000000..2f28865 --- /dev/null +++ b/Samples/NetFramework/NetFramework.csproj @@ -0,0 +1,63 @@ + + + + + + Debug + AnyCPU + {E3DEABA8-40FD-45CE-8868-A7589F610007} + Library + Properties + NetFramework + NetFramework + v4.7.2 + 512 + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + ..\..\packages\DllExports.0.1.0\lib\netstandard2.0\DllExports.dll + False + + + + + + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + \ No newline at end of file diff --git a/Samples/NetFramework/Properties/AssemblyInfo.cs b/Samples/NetFramework/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..874fd94 --- /dev/null +++ b/Samples/NetFramework/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("NetFramework")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("NetFramework")] +[assembly: AssemblyCopyright("Copyright © 2023")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("e3deaba8-40fd-45ce-8868-a7589f610007")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/Samples/NetFramework/packages.config b/Samples/NetFramework/packages.config new file mode 100644 index 0000000..d860cc1 --- /dev/null +++ b/Samples/NetFramework/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/Samples/SingleTarget/Class1.cs b/Samples/SingleTarget/Class1.cs new file mode 100644 index 0000000..5afc494 --- /dev/null +++ b/Samples/SingleTarget/Class1.cs @@ -0,0 +1,14 @@ +using System; +using System.Runtime.InteropServices; +using DllExports; + +namespace SingleTarget +{ + public class Class1 + { + [DllExport("MyExport", CallingConvention.Cdecl)] + public static void InternalName() + { + } + } +} diff --git a/Samples/SingleTarget/SingleTarget.csproj b/Samples/SingleTarget/SingleTarget.csproj new file mode 100644 index 0000000..27f4e7c --- /dev/null +++ b/Samples/SingleTarget/SingleTarget.csproj @@ -0,0 +1,11 @@ + + + + netstandard2.0 + + + + + + + diff --git a/Version.props b/Version.props new file mode 100644 index 0000000..7f45544 --- /dev/null +++ b/Version.props @@ -0,0 +1,8 @@ + + + 0.1.0 + 0.1.0.0 + 0.1.0.0 + 0.1.0 + + diff --git a/appveyor.yml b/appveyor.yml new file mode 100644 index 0000000..9133bfc --- /dev/null +++ b/appveyor.yml @@ -0,0 +1,33 @@ +version: 'Build #{build}' +image: Visual Studio 2019 +configuration: Release +environment: + # Don't bother setting up a package cache + DOTNET_SKIP_FIRST_TIME_EXPERIENCE: 1 + DOTNET_CLI_TELEMETRY_OPTOUT: 1 +install: + - ps: . .\build.ps1 +before_build: + # Restore NuGet packages + - ps: dotnet restore +build_script: + - ps: | + build + dotnet build DllExports.Tests --no-dependencies +before_test: + # Build NuGet packages + - ps: pack +test_script: + - ps: test + - vstest.console /logger:Appveyor DllExports.Tests\bin\%CONFIGURATION%\net472\DllExports.Tests.dll + - vstest.console /logger:Appveyor DllExports.Tests\bin\%CONFIGURATION%\net5.0\DllExports.Tests.dll +#on_finish: +# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) +artifacts: + - path: '*.nupkg' +skip_commits: + files: + - '**/*.md' + - '**/*.yml' + - '**/*.nuspec' +skip_tags: true diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..e2939e2 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,261 @@ +$ErrorActionPreference = "Stop" +$dllExportsMSBuild = Join-Path $PSScriptRoot DllExports.MSBuild +$dllExportsMSBuildBin = Join-Path (Join-Path $dllExportsMSBuild "bin") "Release" + +function build +{ + dotnet build $dllExportsMSBuild -c Release +} + +function pack +{ + gci $PSScriptRoot *.nupkg -Recurse | foreach { Remove-Item $_.FullName -Force } + + dotnet pack -c Release $dllExportsMSBuild + + $nupkg = gci $dllExportsMSBuildBin *.nupkg + + $originalExtension = $nupkg.Extension + $newName = $nupkg.Name -replace $originalExtension,".zip" + $newPath = Join-Path $nupkg.DirectoryName $newName + + if (Test-Path $newPath) + { + Remove-item $newPath + } + + $extractFolder = $nupkg.FullName -replace $nupkg.Extension,"" + + if (Test-Path $extractFolder) + { + Remove-Item $extractFolder -Recurse -Force + } + + try + { + $newItem = Rename-Item -Path $nupkg.FullName -NewName $newName -PassThru + + Expand-Archive $newItem.FullName $extractFolder + + $libDir = Join-Path $extractFolder "lib" + $tasksDir = Join-Path $extractFolder "tasks" + + Copy-Item $libDir $tasksDir -Recurse + + $badDlls = gci $libDir -Recurse -File | where Name -ne "DllExports.dll" + $badDlls | foreach { Remove-Item $_.FullName -Force } + + # Tasks are not supported on netstandard2.0 + Remove-Item (Join-Path $tasksDir "netstandard2.0") -Recurse -Force + + # We don't need libs for net472 and net5.0 + Remove-Item (Join-path $libDir "net472") -Recurse -Force + Remove-Item (Join-path $libDir "net5.0") -Recurse -Force + + $nuspecFile = Join-Path $extractFolder "DllExports.nuspec" + + $lines = gc $nuspecFile + $newLines = $lines | where { !$_.Contains(".NETFramework4.7.2") -and !$_.Contains("net5.0") } + $newLines | Set-Content $nuspecFile + + gci $extractFolder | Compress-Archive -DestinationPath $newItem.FullName -Force + } + finally + { + Remove-Item $extractFolder -Recurse -Force + Rename-Item $newItem.FullName $nupkg.Name + } + + $destination = Join-Path $PSScriptRoot $nupkg.Name + Move-item $nupkg.FullName $destination + + Write-Host "NuGet package created at $destination" -ForegroundColor Green +} + +function test +{ + [CmdletBinding()] + param( + [Parameter(Position=0)] + [string[]]$Samples, + + [string]$Configuration = "Release" + ) + + $kernel32 = ([System.Management.Automation.PSTypeName]"PInvoke.Kernel32").Type + + if(!$kernel32) + { + $str = @" +[DllImport("kernel32.dll", SetLastError = true)] +public static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + +[DllImport("kernel32.dll", EntryPoint = "LoadLibraryW", CharSet = CharSet.Unicode, SetLastError = true)] +public static extern IntPtr LoadLibrary(string lpLibFileName); + +[DllImport("kernel32.dll", SetLastError = true)] +public static extern bool FreeLibrary(IntPtr hLibModule); +"@ + + $kernel32 = Add-Type -Name Kernel32 -MemberDefinition $str -Namespace "PInvoke" -PassThru + } + + + $nupkg = gci $PSScriptRoot *.nupkg + + if(!$nupkg) + { + throw "Package must be built before running tests" + } + + $repoPath = Join-Path $env:temp DllExportsTempRepo + $nugetConfigPath = Join-Path $PSScriptRoot "NuGet.config" + + # Cleanup any files from previous runs + + if(Test-Path $repoPath) + { + Remove-Item $repoPath -Recurse -Force + } + + if(Test-Path $nugetConfigPath) + { + Remove-Item $nugetConfigPath -Recurse -Force + } + + $nugetCache = Join-Path (Join-Path (Join-Path $env:USERPROFILE .nuget) "packages") "dllexports" + + if(Test-Path $nugetCache) + { + Remove-Item $nugetCache -Recurse -Force + } + + try + { + # Add a config file pointing to a local repo containing our nupkg + + nuget add $nupkg.FullName -Source $repoPath + + $nugetConfig = @" + + + + + +"@ + + Set-Content $nugetConfigPath $nugetConfig + + $sampleDirs = gci $PSScriptRoot\Samples + + foreach($sample in $sampleDirs) + { + if($Samples -ne $null -and $sample.Name -notin $Samples) + { + continue + } + + if ($sample.Name -eq "NetFramework") + { + $csproj = Join-Path $sample.FullName "NetFramework.csproj" + $csprojContent = gc $csproj -Raw + $dllExportVersion = [regex]::Match($csprojContent, ".+?(DllExports\..+?)\\").groups[1].Value + + $newContent = $csprojContent -replace $dllExportVersion,$nupkg.BaseName + Set-Content $csproj $newContent -NoNewline -Encoding UTF8 + + $packagesConfig = Join-Path $sample.FullName "packages.config" + $packagesConfigContent = gc $packagesConfig -Raw + $oldVersion = $dllExportVersion -replace "DllExports.","" + $newVersion = $nupkg.BaseName -replace "DllExports.","" + $newContent = $packagesConfigContent -replace $oldVersion,$newVersion + Set-Content $packagesConfig $newContent -NoNewline -Encoding UTF8 + + nuget restore $csproj -SolutionDirectory $PSScriptRoot + } + + Write-Host "Testing sample $($sample.FullName)" -ForegroundColor Magenta + + $obj = Join-Path $sample.FullName obj + $bin = Join-Path $sample.FullName bin + + if (Test-Path $obj) + { + Remove-Item $obj -Recurse -Force + } + + if (Test-Path $bin) + { + Remove-Item $bin -Recurse -Force + } + + dotnet build $sample.FullName -c $Configuration /p:DllExportsArchitectureNameFormat="{name}" /p:DllExportsArchitectures=AMD64 + + if($? -eq $false) + { + throw "$($sample.Name) build failed" + } + + $binDir = Join-Path $bin $Configuration + + $targetFrameworks = gci $binDir -Directory + + if($targetFrameworks.Length -eq 0) + { + # .NET Framework + $targetFrameworks = @(gi $binDir) + } + + foreach($targetFramework in $targetFrameworks) + { + Write-Host " Checking $targetFramework" -ForegroundColor Cyan + + $dll = gci $targetFramework.FullName *.dll + + if(@($dll).Count -ne 1) + { + throw "Expected exactly 1 DLL but this was not the case" + } + + $hModule = $kernel32::LoadLibrary($dll.FullName) + + try + { + if($hModule) + { + $export = $kernel32::GetProcAddress($hModule, "MyExport") + + if($export -ne 0) + { + Write-Host " Found 'MyExport' at 0x$($export.ToString('X'))" -ForegroundColor Green + } + else + { + throw "Failed to find 'MyExport' in sample '$($sample.Name)/$($targetFramework.Name)'" + } + } + else + { + throw "Failed to load $($dll.FullName)" + } + } + finally + { + $kernel32::FreeLibrary($hModule) | Out-Null + } + } + } + } + finally + { + if(Test-Path $repoPath) + { + Remove-Item $repoPath -Recurse -Force + } + + if(Test-Path $nugetConfigPath) + { + Remove-Item $nugetConfigPath -Recurse -Force + } + } +}