Skip to content

Instantly share code, notes, and snippets.

@marcussacana
Last active May 12, 2021 11:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save marcussacana/afcb97f72d150f2f666cf2afce9f946c to your computer and use it in GitHub Desktop.
Save marcussacana/afcb97f72d150f2f666cf2afce9f946c to your computer and use it in GitHub Desktop.
Call Native Linux Functions From wine 4.x with .net framework
//UnixGate.cs - BY MARCUSSACANA
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Text;
internal static class UnixGate
{
static bool Enabled;
static byte[] OriLoadLibExW;
static byte[] JmpLoadLibExW;
static byte[] OriGetProc;
static byte[] JmpGetProc;
static IntPtr LoadLibraryExWAddr;
static IntPtr GetProcAddr;
static IntPtr BaseLoadLibExAddr;
static IntPtr BaseGetProcAddr;
static IntPtr dlsymAddr;
static IntPtr dlopenAddr;
static IntPtr GetModFN;
static IntPtr GetProcHookAddr;
static IntPtr LoadLibExWHookAddr;
static IntPtr Kernel;
static IntPtr KernelBase;
internal static void Initialize()
{
if (LoadLibraryExWAddr != IntPtr.Zero)
return;
Kernel = LoadLibraryW("kernel32");
KernelBase = LoadLibraryW("kernelbase");
LoadLibraryExWAddr = GetProcAddressManaged(Kernel, "LoadLibraryExW");
GetProcAddr = GetProcAddressManaged(Kernel, "GetProcAddress");
GetModFN = GetProcAddressManaged(Kernel, "GetModuleFileNameA");
BaseGetProcAddr = GetProcAddressManaged(KernelBase, "GetProcAddress");
BaseLoadLibExAddr = GetProcAddressManaged(KernelBase, "LoadLibraryExW");
if (BaseLoadLibExAddr == IntPtr.Zero || BaseGetProcAddr == IntPtr.Zero)
throw new Exception("Unsupported Wine Version");
OriLoadLibExW = Read(LoadLibraryExWAddr, (uint)JmpSize);
OriGetProc = Read(GetProcAddr, (uint)JmpSize);
dGetProcAddr = new GetProcAddressDel(GetProcHook);
dLoadLibExW = new LoadLibraryExWDel(LoadLibraryExHook);
dLoadLibExWReal = (LoadLibraryExWDel)Marshal.GetDelegateForFunctionPointer(BaseLoadLibExAddr, typeof(LoadLibraryExWDel));
dGetModuleFileName = (GetModuleFileNameDel)Marshal.GetDelegateForFunctionPointer(GetModFN, typeof(GetModuleFileNameDel));
GetProcHookAddr = Marshal.GetFunctionPointerForDelegate(dGetProcAddr);
LoadLibExWHookAddr = Marshal.GetFunctionPointerForDelegate(dLoadLibExW);
dlopenAddr = GetSymbol("libwine.so.1!wine_dlopen");
dlsymAddr = GetSymbol("libwine.so.1!wine_dlsym");
if (dlopenAddr == IntPtr.Zero || dlsymAddr == IntPtr.Zero)
throw new Exception("Invalid Wine Envoriment: Failed to Load the libwine.so");
byte[] PEBData = NTCurPebData;
var PEBAddr = Marshal.AllocHGlobal(PEBData.Length);
Write(PEBAddr, PEBData, Protection.PAGE_EXECUTE_READWRITE);
NtCurrentPeb = (Ret0)Marshal.GetDelegateForFunctionPointer(PEBAddr, typeof(Ret0));
ddlopen = (dlopenDel)Marshal.GetDelegateForFunctionPointer(dlopenAddr, typeof(dlopenDel));
ddlsym = (dlsymDel)Marshal.GetDelegateForFunctionPointer(dlsymAddr, typeof(dlsymDel));
JmpGetProc = AssembleJump(GetProcAddr, GetProcHookAddr);
JmpLoadLibExW = AssembleJump(LoadLibraryExWAddr, LoadLibExWHookAddr);
}
internal static void Enable()
{
if (Enabled)
return;
Initialize();
Write(GetProcAddr, JmpGetProc);
Write(LoadLibraryExWAddr, JmpLoadLibExW);
Enabled = true;
}
internal static void Disable()
{
if (!Enabled)
return;
Write(GetProcAddr, OriGetProc);
Write(LoadLibraryExWAddr, OriLoadLibExW);
Enabled = false;
}
static IntPtr GetProcHook(IntPtr hModule, IntPtr Proc)
{
IntPtr WinFunc = GetProcAddressManaged(hModule, Proc);
if (WinFunc == IntPtr.Zero && hModule != IntPtr.Zero)
{
string ModuleFN = GetModuleFileName(hModule);
if (string.IsNullOrEmpty(ModuleFN))
return dlsym(hModule, Proc);
}
return WinFunc;
}
static IntPtr LoadLibraryExHook(string Name, IntPtr Reserved, LoadLibraryFlags Flags)
{
IntPtr hModule = dLoadLibExWReal(Name, Reserved, Flags);
if (hModule == IntPtr.Zero)
return dlopen(Name, RTLD_NOW);
return hModule;
}
static IntPtr GetProcAddressManaged(IntPtr hModule, string ProcName)
{
IntPtr Proc = Marshal.StringToHGlobalAnsi(ProcName);
IntPtr Rst = GetProcAddressManaged(hModule, Proc);
Marshal.FreeHGlobal(Proc);
return Rst;
}
static IntPtr GetProcAddressManaged(IntPtr hModule, IntPtr ProcNamePtr)
{
ushort Ordinal = 0;
if (ProcNamePtr.ToUlong() > ushort.MaxValue)
{
var ProcedureName = new ANSI_STRING();
var ProcName = Marshal.PtrToStringAnsi(ProcNamePtr);
ProcedureName.Length = (short)ProcName.Length;
ProcedureName.MaximumLength = ProcedureName.Length;
ProcedureName.Buffer = ProcName;
var Addr = Marshal.AllocHGlobal(ProcName.Length + 5);
Marshal.StructureToPtr(ProcedureName, Addr, false);
ProcNamePtr = Addr;
}
else
Ordinal = (ushort)ProcNamePtr.ToUlong();
IntPtr hMapped = BasepMapModuleHandle(hModule, false);
var Status = LdrGetProcedureAddress(hMapped, ProcNamePtr, Ordinal, out IntPtr fnExp);
Marshal.FreeHGlobal(ProcNamePtr);
if (!NT_SUCCESS(Status))
{
SetLastError(Status.ToInt32());
return IntPtr.Zero;
}
if (fnExp == hMapped)
{
if (ProcNamePtr.ToUlong() > ushort.MaxValue)
SetLastError(0xC0000139);//STATUS_ENTRYPOINT_NOT_FOUND
else
SetLastError(0xC0000138);//STATUS_ORDINAL_NOT_FOUND
return IntPtr.Zero;
}
return fnExp;
}
static IntPtr BasepMapModuleHandle(IntPtr hModule, bool AsDataFile)
{
if (hModule == IntPtr.Zero)
return GetCurrentImageBaseAddress();
if ((hModule.ToUlong() & 1) != 0 && AsDataFile)
return IntPtr.Zero;
return hModule;
}
static IntPtr GetCurrentImageBaseAddress()
{
var PEB = NtCurrentPeb();
return Marshal.ReadIntPtr(PEB, IntPtr.Size == 8 ? 0x10 : 0x08);
}
static bool? IsRealWin = null;
public static bool IsRealWindows
{
get
{
if (IsRealWin.HasValue)
return IsRealWin.Value;
IntPtr hModule = GetModuleHandle("ntdll.dll");
if (hModule == IntPtr.Zero)
IsRealWin = false;
else
{
IntPtr fptr = GetProcAddressManaged(hModule, "wine_get_version");
IsRealWin = fptr == IntPtr.Zero;
}
return IsRealWin.Value;
}
}
static bool NT_SUCCESS(IntPtr STATUS) => STATUS.ToInt32() >= 0;
static Ret0 NtCurrentPeb;
delegate IntPtr Ret0();
/*
#include <intrin.h>
void* NtCurrentPeb()
{
#ifdef _WIN64
return (void*)__readgsqword(0x60);
#else
__asm {
mov eax, fs:[0x30];
}
#endif
}
*/
static byte[] NTCurPebData
{
get
{
if (IntPtr.Size == 8)
return NTCurPebx64;
return NTCurPebx86;
}
}
static readonly byte[] NTCurPebx64 = new byte[] {
0x65, 0x48, 0x8B, 0x04, 0x25, 0x60,
0x00, 0x00, 0x00, 0xC2, 0x00, 0x00
};
static readonly byte[] NTCurPebx86 = new byte[] {
0x64, 0xA1, 0x30, 0x00, 0x00, 0x00, 0xC2, 0x00, 0x00
};
const uint SYMOPT_DEFERRED_LOADS = 0x00000004;
const uint SYMOPT_PUBLICS_ONLY = 0x00004000;
internal const uint RTLD_NOW = 0x002;
static GetProcAddressDel dGetProcAddr;
static LoadLibraryExWDel dLoadLibExW;
static LoadLibraryExWDel dLoadLibExWReal;
static dlsymDel ddlsym;
static dlopenDel ddlopen;
[UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Ansi, SetLastError = true)]
delegate IntPtr GetProcAddressDel(IntPtr hModule, IntPtr Proc);
[UnmanagedFunctionPointer(CallingConvention.Winapi, CharSet = CharSet.Unicode)]
delegate IntPtr LoadLibraryExWDel(string lpFileName, IntPtr hReservedNull, LoadLibraryFlags dwFlags);
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi, SetLastError = true)]
delegate IntPtr dlsymDel(IntPtr hModule, IntPtr Symbol, IntPtr StrError, uint ErrorSize);
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi, SetLastError = true)]
delegate IntPtr dlopenDel(IntPtr lpFileName, uint Flags, IntPtr StrError, uint ErrorSize);
internal static IntPtr dlsym(IntPtr hModule, string Symbol) => dlsym(hModule, Marshal.StringToHGlobalAnsi(Symbol));
internal static IntPtr dlopen(string lpFilename, uint Flags) => dlopen(Marshal.StringToHGlobalAnsi(lpFilename), Flags);
internal static IntPtr dlsym(IntPtr hModule, IntPtr Symbol)
{
if (IntPtr.Size == 8)
return UnixFastCall(dlsymAddr, hModule, Symbol, IntPtr.Zero, 0);
return ddlsym(hModule, Symbol, IntPtr.Zero, 0);
}
internal static IntPtr dlopen(IntPtr lpFileName, uint Flags)
{
if (IntPtr.Size == 8)
return UnixFastCall(dlopenAddr, lpFileName, Flags, IntPtr.Zero, 0);
return ddlopen(lpFileName, Flags, IntPtr.Zero, 0);
}
public static IntPtr UnixFastCall(IntPtr Function, params object[] Paramters)
{
IntPtr[] Args = new IntPtr[Paramters.Length];
for (int i = 0; i < Paramters.Length; i++)
{
object Paramter = Paramters[i];
IntPtr NParamter = IntPtr.Zero;
switch (Type.GetTypeCode(Paramter.GetType()))
{
case TypeCode.Boolean:
NParamter = new IntPtr(((bool)Paramter) ? 1 : 0);
break;
case TypeCode.Byte:
NParamter = new IntPtr((byte)Paramter);
break;
case TypeCode.SByte:
NParamter = new IntPtr((sbyte)Paramter);
break;
case TypeCode.Int16:
NParamter = new IntPtr((short)Paramter);
break;
case TypeCode.UInt16:
NParamter = new IntPtr((ushort)Paramter);
break;
case TypeCode.Int32:
NParamter = new IntPtr((int)Paramter);
break;
case TypeCode.UInt32:
NParamter = new IntPtr((uint)Paramter);
break;
case TypeCode.Int64:
NParamter = new IntPtr((long)Paramter);
break;
case TypeCode.UInt64:
NParamter = new IntPtr(unchecked((long)(ulong)Paramter));
break;
case TypeCode.Char:
NParamter = new IntPtr((char)Paramter);
break;
case TypeCode.String:
NParamter = Marshal.StringToHGlobalAnsi(((string)Paramter));
break;
default:
var TP = Paramter.GetType();
if (Paramter is IntPtr)
NParamter = (IntPtr)Paramter;
else if (Paramter is UIntPtr)
NParamter = new IntPtr(unchecked((long)((UIntPtr)Paramter).ToUInt64()));
else if (TP.IsValueType && !TP.IsEnum)
{ //Is Struct
NParamter = Marshal.AllocHGlobal(Marshal.SizeOf(Paramter));
Marshal.StructureToPtr(Paramter, NParamter, false);
}
else
throw new ArgumentException(Paramter.ToString() + " Can't be automatically parsed.");
break;
}
Args[i] = NParamter;
}
return UnixFastCall(Function, Args);
}
public static IntPtr UnixFastCall(IntPtr Function, params IntPtr[] Paramters)
{
List<byte> Buffer = new List<byte>();
for (int i = 0; i < Paramters.Length; i++)
{
switch (i)
{
case -1:
Buffer.AddRange(BitConverter.GetBytes(Paramters[i].ToUlong()));
break;
case 0:
Buffer.AddRange(new byte[] { 0x48, 0xbf });//RDI
goto case -1;
case 1:
Buffer.AddRange(new byte[] { 0x48, 0xbe });//RSI
goto case -1;
case 2:
Buffer.AddRange(new byte[] { 0x48, 0xba });//RDX
goto case -1;
case 3:
Buffer.AddRange(new byte[] { 0x48, 0xb9 });//RCX
goto case -1;
case 4:
Buffer.AddRange(new byte[] { 0x48, 0xb8 });//R8
goto case -1;
case 5:
Buffer.AddRange(new byte[] { 0x48, 0xb9 });//R9
goto case -1;
default:
Buffer.AddRange(new byte[] { 0x48, 0xb8 });//RAX
Buffer.AddRange(BitConverter.GetBytes(Paramters[i].ToUlong()));
Buffer.Add(0x50);//push rax
break;
}
}
Buffer.AddRange(new byte[] { 0x48, 0xb8 });//RAX
Buffer.AddRange(BitConverter.GetBytes(Function.ToUlong()));
Buffer.AddRange(new byte[] { 0xFF, 0xE0 });//jmp RAX
IntPtr TmpFunc = Marshal.AllocHGlobal(Buffer.Count);
Write(TmpFunc, Buffer.ToArray(), Protection.PAGE_EXECUTE_READWRITE);
Ret0 TmpDel = (Ret0)Marshal.GetDelegateForFunctionPointer(TmpFunc, typeof(Ret0));
IntPtr Result = TmpDel();
Marshal.FreeHGlobal(TmpFunc);
return Result;
}
static IntPtr GetSymbol(string name) => GetSymbol(System.Diagnostics.Process.GetCurrentProcess().Handle, name);
static IntPtr GetSymbol(IntPtr hProcess, string name)
{
unchecked
{
IntPtr ret = IntPtr.Zero;
SymSetOptions(SYMOPT_DEFERRED_LOADS | SYMOPT_PUBLICS_ONLY | 0x40000000);
if (SymInitialize(hProcess, null, true))
{
var si = new SYMBOL_INFO();
si.SizeOfStruct = 592;
si.MaxNameLen = 512;
IntPtr Addr = Marshal.AllocHGlobal(1024);
Marshal.StructureToPtr(si, Addr, false);
#if false
if (SymFromName(hProcess, name, Addr))
{
ret = Marshal.ReadIntPtr(Addr, 56);
var str = Marshal.PtrToStringAnsi(new IntPtr((long)((ulong)Addr.ToInt64() + 84)));
Console.WriteLine("SymFromName Sucess 0x{0:X8}, {1}", IntPtr.Size == 8 ? ret.ToInt64() : ret.ToInt32(), str);
}
else
Console.WriteLine("SymFromName Failed");
#else
if (SymFromName(hProcess, name, Addr))
ret = Marshal.ReadIntPtr(Addr, 56);
#endif
SymCleanup(hProcess);
}
return ret;
}
}
static readonly int JmpSize = IntPtr.Size == 8 ? 12 : 5;
static byte[] AssembleJump(IntPtr From, IntPtr Destination)
{
byte[] jmp = new byte[JmpSize];
if (IntPtr.Size == 8)
{
//x64
new byte[] { 0x48, 0xb8 }.CopyTo(jmp, 0);
BitConverter.GetBytes(unchecked((ulong)Destination.ToInt64())).CopyTo(jmp, 2);
new byte[] { 0xFF, 0xE0 }.CopyTo(jmp, 10);
}
else
{
//x86
jmp[0] = 0xE9;
int Result = (int)(Destination.ToInt64() - From.ToInt64() - JmpSize);
BitConverter.GetBytes(Result).CopyTo(jmp, 1);
}
return jmp;
}
static byte[] Read(IntPtr Address, uint Length)
{
byte[] Buffer = new byte[Length];
if (!ChangeProtection(Address, Buffer.Length, Protection.PAGE_EXECUTE_READWRITE, out Protection Original))
throw new Exception($"Falied to change the R/W memory permissions at {Address.ToInt64():X8}");
Marshal.Copy(Address, Buffer, 0, Buffer.Length);
if (!ChangeProtection(Address, Buffer.Length, Original))
throw new Exception($"Falied to restore the memory permissions at {Address.ToInt64():X8}");
return Buffer;
}
static bool Write(IntPtr Address, byte[] Content, Protection? NewProtection = null)
{
ChangeProtection(Address, Content.Length, Protection.PAGE_EXECUTE_READWRITE, out Protection Original);
uint Saved = (uint)Content.LongLength;
Marshal.Copy(Content, 0, Address, Content.Length);
if (NewProtection.HasValue)
ChangeProtection(Address, Content.Length, NewProtection.Value);
else
ChangeProtection(Address, Content.Length, Original);
if (Saved != Content.Length)
return false;
return true;
}
static bool ChangeProtection(IntPtr Address, int Range, Protection Protection, out Protection OriginalProtection)
{
return VirtualProtect(Address, Range, Protection, out OriginalProtection);
}
static bool ChangeProtection(IntPtr Address, int Range, Protection Protection)
{
return VirtualProtect(Address, Range, Protection, out _);
}
[DllImport("kernel32", SetLastError = true)]
static extern bool VirtualProtect(IntPtr lpAddress, int dwSize, Protection flNewProtect, out Protection lpflOldProtect);
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
static extern IntPtr LoadLibraryW(string lpFileName);
[DllImport("kernel32.dll", CharSet = CharSet.Auto)]
static extern IntPtr GetModuleHandle(string lpModuleName);
[UnmanagedFunctionPointer(CallingConvention.Winapi, SetLastError = true)]
delegate uint GetModuleFileNameDel([In] IntPtr hModule, [Out] StringBuilder lpFilename, [In][MarshalAs(UnmanagedType.U4)] int nSize);
static GetModuleFileNameDel dGetModuleFileName;
static string GetModuleFileName(IntPtr hModule)
{
StringBuilder fileName = new StringBuilder(1024);
if (dGetModuleFileName(hModule, fileName, fileName.Capacity) == 0)
return null;
return fileName.ToString();
}
internal static ulong ToUlong(this IntPtr Pointer) => unchecked((ulong)Pointer.ToInt64());
[DllImport("dbghelp.dll")]
static extern IntPtr SymSetOptions(uint SymOptions);
[DllImport("dbghelp.dll")]
static extern bool SymInitialize(IntPtr hProcess, string UserSearchPath, bool fInvadeProcess);
[DllImport("dbghelp.dll")]
static extern bool SymCleanup(IntPtr hProcess);
[DllImport("dbghelp.dll")]
static extern bool SymFromName(IntPtr hProcess, string Name, IntPtr Symbol);
[DllImport("ntdll.dll")]
static extern IntPtr LdrGetProcedureAddress(IntPtr hModule, IntPtr FunctionName, ushort Oridinal, out IntPtr FunctionAddress);
[DllImport("kernel32.dll", SetLastError = true)]
static extern void SetLastError(int ErrorCode);
[DllImport("kernel32.dll", SetLastError = true)]
static extern void SetLastError(uint ErrorCode);
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
struct SYMBOL_INFO
{
public uint SizeOfStruct;//+0
public uint TypeIndex;//+4
public ulong ReservedA;//+8
public ulong ReservedB;//+16
public uint Index;//+24
public uint Size;//+28
public ulong ModBase;//+32
public uint Flags;//+40
public ulong Value;//+44
public ulong Address;//+52
public uint Register;//+60
public uint Scope;//+64
public uint Tag;//+68
public uint NameLen;//+72
public uint MaxNameLen;//+76
[MarshalAs(UnmanagedType.ByValTStr, SizeConst = 512)]
public string Name;//+80
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Ansi)]
public struct ANSI_STRING
{
public short Length;
public short MaximumLength;
public string Buffer;
}
[Flags]
enum LoadLibraryFlags : uint
{
None = 0,
DONT_RESOLVE_DLL_REFERENCES = 0x00000001,
LOAD_IGNORE_CODE_AUTHZ_LEVEL = 0x00000010,
LOAD_LIBRARY_AS_DATAFILE = 0x00000002,
LOAD_LIBRARY_AS_DATAFILE_EXCLUSIVE = 0x00000040,
LOAD_LIBRARY_AS_IMAGE_RESOURCE = 0x00000020,
LOAD_LIBRARY_SEARCH_APPLICATION_DIR = 0x00000200,
LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000,
LOAD_LIBRARY_SEARCH_DLL_LOAD_DIR = 0x00000100,
LOAD_LIBRARY_SEARCH_SYSTEM32 = 0x00000800,
LOAD_LIBRARY_SEARCH_USER_DIRS = 0x00000400,
LOAD_WITH_ALTERED_SEARCH_PATH = 0x00000008
}
enum Protection
{
PAGE_NOACCESS = 0x01,
PAGE_READONLY = 0x02,
PAGE_READWRITE = 0x04,
PAGE_WRITECOPY = 0x08,
PAGE_EXECUTE = 0x10,
PAGE_EXECUTE_READ = 0x20,
PAGE_EXECUTE_READWRITE = 0x40,
PAGE_EXECUTE_WRITECOPY = 0x80,
PAGE_GUARD = 0x100,
PAGE_NOCACHE = 0x200,
PAGE_WRITECOMBINE = 0x400
}
}
using System;
using System.Runtime.InteropServices;
namespace UnixGateTest
{
class Program
{
static void Main(string[] args)
{
Console.Title = "UnixGate Test";
Console.WriteLine("Run Sample A or B?");
Console.WriteLine($"{IntPtr.Size} Bytes - {Environment.Is64BitProcess}");
char c = char.ToUpper(Console.ReadKey().KeyChar);
Console.WriteLine();
switch (c)
{
case 'A':
SampleA();
break;
default:
SampleB();
break;
}
}
/// <summary>
/// Manual Method! x86 and x64
/// </summary>
static void SampleA()
{
UnixGate.Initialize();
var hModule = UnixGate.dlopen("libc.so.6", UnixGate.RTLD_NOW);
var hProc = UnixGate.dlsym(hModule, "system");
if (IntPtr.Size == 8)
{
UnixGate.UnixFastCall(hProc, "notify-send --icon=wine \"Hello World B x64 from .net framework in wine!\"");
}
else
{
var dSystem = (systemDel)Marshal.GetDelegateForFunctionPointer(hProc, typeof(systemDel));
dSystem("notify-send --icon=wine \"Hello World B x86 from .net framework in wine!\"");
}
return;
}
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
delegate void systemDel(string Command);
/// <summary>
/// Automatic Method x86 Only
/// </summary>
static void SampleB()
{
if (IntPtr.Size == 8) {
Console.WriteLine("The .Net Framework fastcall isn't compatible with the GCC fastcall");
return;
}
UnixGate.Enable();
system("notify-send --icon=wine \"Hello World A from .net framework in wine!\"");
return;
}
[DllImport("libc.so.6", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.Cdecl)]
static extern void system(string Command);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment