Added the assembly loader with Azure Blob Storage

Added the assembly loader with Azure Blob Storage
This commit is contained in:
Namhyeon Go 2025-12-04 14:54:21 +09:00
parent e84a69d929
commit 07e47338cb
7 changed files with 386 additions and 258 deletions

View File

@ -0,0 +1,368 @@
// AssemblyLoader.cs
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System;
using System.Collections.Generic;
using System.IO;
using System.Net;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
namespace WelsonJS.Launcher
{
/// <summary>
/// Network-aware loader for managed (.NET) and native (C/C++) binaries.
/// - Managed assemblies resolve via AssemblyResolve
/// - Native modules explicitly loaded via LoadNativeModules(...)
/// - All DLLs must have valid Authenticode signatures
/// - Cached at: %APPDATA%\WelsonJS\assembly\{Name}\{Version}\
/// - BaseUrl must be set by Main() before calling Register()
/// </summary>
public static class AssemblyLoader
{
/// <summary>
/// Base URL for downloading managed/native binaries.
/// Example: https://catswords.blob.core.windows.net/welsonjs/packages
/// Must be set before Register() or LoadNativeModules().
/// </summary>
public static string BaseUrl { get; set; } = null;
private static readonly object SyncRoot = new object();
private static bool _registered;
private static readonly string LoaderNamespace = typeof(AssemblyLoader).Namespace ?? "WelsonJS.Launcher";
private static readonly HttpClient Http = new HttpClient();
private static readonly ICompatibleLogger Logger = new TraceLogger();
// -------------------- kernel32 native loading --------------------
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr LoadLibrary(string lpFileName);
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool SetDllDirectory(string lpPathName);
// -------------------- WinVerifyTrust (signature verification) --------------------
private const uint ERROR_SUCCESS = 0x00000000;
private const uint TRUST_E_NOSIGNATURE = 0x800B0100;
private const uint TRUST_E_EXPLICIT_DISTRUST = 0x800B0111;
private const uint TRUST_E_SUBJECT_NOT_TRUSTED = 0x800B0004;
private const uint CRYPT_E_SECURITY_SETTINGS = 0x80092026;
private static readonly Guid WINTRUST_ACTION =
new Guid("00aac56b-cd44-11d0-8cc2-00c04fc295ee");
[DllImport("wintrust.dll", CharSet = CharSet.Unicode)]
private static extern uint WinVerifyTrust(
IntPtr hwnd,
[MarshalAs(UnmanagedType.LPStruct)] Guid pgActionID,
ref WINTRUST_DATA pWVTData);
private enum FileSignatureStatus { Valid, NoSignature, Invalid }
// -------------------- WinTrust Structures --------------------
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WINTRUST_FILE_INFO
{
public uint cbStruct;
[MarshalAs(UnmanagedType.LPWStr)] public string pcwszFilePath;
public IntPtr hFile;
public IntPtr pgKnownSubject;
public WINTRUST_FILE_INFO(string filePath)
{
cbStruct = (uint)Marshal.SizeOf(typeof(WINTRUST_FILE_INFO));
pcwszFilePath = filePath;
hFile = IntPtr.Zero;
pgKnownSubject = IntPtr.Zero;
}
}
private enum WinTrustDataUIChoice : uint { None = 2 }
private enum WinTrustDataRevocationChecks : uint { None = 0 }
private enum WinTrustDataChoice : uint { File = 1 }
private enum WinTrustDataStateAction : uint { Ignore = 0 }
private enum WinTrustDataUIContext : uint { Execute = 0 }
[Flags]
private enum WinTrustDataProvFlags : uint
{
RevocationCheckNone = 0x00000010,
DisableMD2andMD4 = 0x00002000
}
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
private struct WINTRUST_DATA
{
public uint cbStruct;
public IntPtr pPolicyCallbackData;
public IntPtr pSIPClientData;
public WinTrustDataUIChoice dwUIChoice;
public WinTrustDataRevocationChecks dwRevocationChecks;
public WinTrustDataChoice dwUnionChoice;
public IntPtr pFile;
public WinTrustDataStateAction dwStateAction;
public IntPtr hWVTStateData;
public string pwszURLReference;
public WinTrustDataProvFlags dwProvFlags;
public WinTrustDataUIContext dwUIContext;
public WINTRUST_DATA(IntPtr pFileInfo)
{
cbStruct = (uint)Marshal.SizeOf(typeof(WINTRUST_DATA));
pPolicyCallbackData = IntPtr.Zero;
pSIPClientData = IntPtr.Zero;
dwUIChoice = WinTrustDataUIChoice.None;
dwRevocationChecks = WinTrustDataRevocationChecks.None;
dwUnionChoice = WinTrustDataChoice.File;
pFile = pFileInfo;
dwStateAction = WinTrustDataStateAction.Ignore;
hWVTStateData = IntPtr.Zero;
pwszURLReference = null;
dwProvFlags = WinTrustDataProvFlags.RevocationCheckNone |
WinTrustDataProvFlags.DisableMD2andMD4;
dwUIContext = WinTrustDataUIContext.Execute;
}
}
// ========================================================================
// PUBLIC API
// ========================================================================
/// <summary>
/// Registers AssemblyResolve to download and validate .NET assemblies.
/// </summary>
public static void Register()
{
if (_registered)
return;
if (string.IsNullOrWhiteSpace(BaseUrl))
{
Logger.Error("AssemblyLoader.Register() called but BaseUrl is not set.");
throw new InvalidOperationException("AssemblyLoader.BaseUrl must be configured before Register().");
}
AppDomain.CurrentDomain.AssemblyResolve += OnAssemblyResolve;
_registered = true;
Logger.Info("AssemblyLoader: AssemblyResolve handler registered.");
}
/// <summary>
/// Loads native modules associated with an assembly (explicit).
/// </summary>
public static void LoadNativeModules(string ownerAssemblyName, Version version, IList<string> fileNames)
{
if (string.IsNullOrWhiteSpace(BaseUrl))
throw new InvalidOperationException("AssemblyLoader.BaseUrl must be set before loading native modules.");
if (ownerAssemblyName == null) throw new ArgumentNullException("ownerAssemblyName");
if (version == null) throw new ArgumentNullException("version");
if (fileNames == null) throw new ArgumentNullException("fileNames");
string versionString = version.ToString();
lock (SyncRoot)
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string cacheDir = Path.Combine(appData, "WelsonJS", "assembly", ownerAssemblyName, versionString);
Directory.CreateDirectory(cacheDir);
try { SetDllDirectory(cacheDir); }
catch { }
foreach (string raw in fileNames)
{
if (string.IsNullOrWhiteSpace(raw))
continue;
string fileName = raw.Trim();
string localPath = Path.Combine(cacheDir, fileName);
if (!File.Exists(localPath))
{
string url = $"{BaseUrl.TrimEnd('/')}/native/{ownerAssemblyName}/{versionString}/{fileName}";
DownloadFile(url, localPath);
Logger.Info("Downloaded native module: {0}", fileName);
}
else
{
Logger.Info("Using cached native module: {0}", localPath);
}
EnsureSignedFileOrThrow(localPath, fileName);
IntPtr h = LoadLibrary(localPath);
if (h == IntPtr.Zero)
{
Logger.Error("LoadLibrary failed for {0}", localPath);
}
else
{
Logger.Info("Loaded native module: {0}", fileName);
}
}
}
}
public static void LoadNativeModules(Assembly asm, IList<string> fileNames)
{
AssemblyName an = asm.GetName();
LoadNativeModules(an.Name, an.Version, fileNames);
}
// ========================================================================
// ASSEMBLY RESOLVE HANDLER (MANAGED)
// ========================================================================
private static Assembly OnAssemblyResolve(object sender, ResolveEventArgs args)
{
Logger.Info("AssemblyResolve: {0}", args.Name);
AssemblyName req = new AssemblyName(args.Name);
string simpleName = req.Name;
if (IsFrameworkAssembly(simpleName))
return null;
var entry = Assembly.GetEntryAssembly();
if (entry != null)
{
var entryName = entry.GetName().Name;
if (string.Equals(simpleName, entryName, StringComparison.OrdinalIgnoreCase))
{
Logger.Info("AssemblyResolve: skipping entry assembly {0}", simpleName);
return null;
}
}
Version version = req.Version ?? new Version(0, 0, 0, 0);
string versionStr = version.ToString();
lock (SyncRoot)
{
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string cacheDir = Path.Combine(appData, "WelsonJS", "assembly", simpleName, versionStr);
string dllPath = Path.Combine(cacheDir, simpleName + ".dll");
Directory.CreateDirectory(cacheDir);
if (!File.Exists(dllPath))
{
string url = $"{BaseUrl.TrimEnd('/')}/managed/{simpleName}/{versionStr}/{simpleName}.dll";
DownloadFile(url, dllPath);
Logger.Info("Downloaded managed assembly: {0}", simpleName);
}
else
{
Logger.Info("Using cached managed assembly: {0}", dllPath);
}
if (!File.Exists(dllPath))
{
Logger.Warn("AssemblyResolve: managed assembly not found after download attempt: {0}", simpleName);
return null;
}
EnsureSignedFileOrThrow(dllPath, simpleName);
return Assembly.LoadFrom(dllPath);
}
}
// ========================================================================
// HELPERS
// ========================================================================
private static void DownloadFile(string url, string dest)
{
HttpResponseMessage res = null;
try
{
res = Http.GetAsync(url).GetAwaiter().GetResult();
if (res.StatusCode == HttpStatusCode.NotFound)
{
Logger.Warn("DownloadFile: 404 Not Found for {0}", url);
return;
}
res.EnsureSuccessStatusCode();
using (Stream s = res.Content.ReadAsStreamAsync().GetAwaiter().GetResult())
using (FileStream fs = new FileStream(dest, FileMode.Create, FileAccess.Write))
{
s.CopyTo(fs);
}
}
catch (HttpRequestException ex)
{
Logger.Error("DownloadFile: HTTP error for {0}: {1}", url, ex.Message);
throw;
}
}
private static bool IsFrameworkAssembly(string name)
{
return name.StartsWith("System", StringComparison.OrdinalIgnoreCase) ||
name.StartsWith("Microsoft", StringComparison.OrdinalIgnoreCase) ||
name == "mscorlib" ||
name == "netstandard" ||
name == "WindowsBase" ||
name == "PresentationCore" ||
name == "PresentationFramework" ||
name.StartsWith(LoaderNamespace);
}
private static void EnsureSignedFileOrThrow(string path, string logicalName)
{
FileSignatureStatus status = VerifySignature(path);
if (status == FileSignatureStatus.Valid)
{
Logger.Info("Signature OK: {0}", logicalName);
return;
}
if (status == FileSignatureStatus.NoSignature)
{
Logger.Error("BLOCKED unsigned binary: {0}", logicalName);
throw new InvalidOperationException("Unsigned binary blocked: " + logicalName);
}
Logger.Error("BLOCKED invalid signature: {0}", logicalName);
throw new InvalidOperationException("Invalid signature: " + logicalName);
}
private static FileSignatureStatus VerifySignature(string file)
{
WINTRUST_FILE_INFO fileInfo = new WINTRUST_FILE_INFO(file);
IntPtr pFile = Marshal.AllocCoTaskMem(Marshal.SizeOf(typeof(WINTRUST_FILE_INFO)));
Marshal.StructureToPtr(fileInfo, pFile, false);
WINTRUST_DATA data = new WINTRUST_DATA(pFile);
uint result = WinVerifyTrust(IntPtr.Zero, WINTRUST_ACTION, ref data);
Marshal.FreeCoTaskMem(pFile);
if (result == ERROR_SUCCESS) return FileSignatureStatus.Valid;
if (result == TRUST_E_NOSIGNATURE) return FileSignatureStatus.NoSignature;
return FileSignatureStatus.Invalid;
}
}
}

View File

@ -1,244 +0,0 @@
// NativeBootstrap.cs
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: 2025 Catswords OSS and WelsonJS Contributors
// https://github.com/gnh1201/welsonjs
//
using System;
using System.Collections.Generic;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Security.Cryptography.X509Certificates;
namespace WelsonJS.Launcher
{
public static class NativeBootstrap
{
// Win32 APIs
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr LoadLibrary(string lpFileName);
[DllImport("kernel32", SetLastError = true)]
private static extern bool SetDefaultDllDirectories(int DirectoryFlags);
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode, EntryPoint = "AddDllDirectory")]
private static extern IntPtr AddDllDirectory(string newDirectory);
[DllImport("kernel32", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern bool SetDllDirectory(string lpPathName);
private const int LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000;
/// <summary>
/// Tries to load native libraries in the following order:
/// 1) %APPDATA%\{appDataSubdirectory}\{dllName}
/// 2) Application base directory\{dllName}
///
/// Signatures:
/// - requireSigned = true : Only loads DLLs with a valid Authenticode chain.
/// - certValidator != null : Additional custom validation (e.g., pinning).
///
/// Must be called before any P/Invoke usage.
/// </summary>
public static void Init(
IEnumerable<string> dllNames,
string appDataSubdirectory,
ICompatibleLogger logger,
bool requireSigned = false,
Func<X509Certificate2, bool> certValidator = null)
{
if (dllNames == null) throw new ArgumentNullException(nameof(dllNames));
if (logger == null) throw new ArgumentNullException(nameof(logger));
string appData = Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData);
string appDataPath = string.IsNullOrEmpty(appDataSubdirectory)
? appData
: Path.Combine(appData, appDataSubdirectory);
string asmLocation = Assembly.GetEntryAssembly()?.Location
?? Assembly.GetExecutingAssembly().Location
?? AppContext.BaseDirectory;
string appBaseDirectory = Path.GetDirectoryName(asmLocation) ?? AppContext.BaseDirectory;
var triedPaths = new List<string>();
foreach (string dllName in dllNames)
{
if (string.IsNullOrWhiteSpace(dllName))
continue;
// 1) %APPDATA% subdirectory
string candidate1 = Path.Combine(appDataPath, dllName);
triedPaths.Add(candidate1);
if (TryLoad(candidate1, logger, requireSigned, certValidator)) return;
// 2) Application base directory
string candidate2 = Path.Combine(appBaseDirectory, dllName);
triedPaths.Add(candidate2);
if (TryLoad(candidate2, logger, requireSigned, certValidator)) return;
}
string message = "Failed to load requested native libraries.\n" +
"Tried:\n " + string.Join("\n ", triedPaths);
logger.Error(message);
throw new FileNotFoundException(message);
}
private static bool TryLoad(
string fullPath,
ICompatibleLogger logger,
bool requireSigned,
Func<X509Certificate2, bool> certValidator)
{
try
{
if (!File.Exists(fullPath))
{
logger.Info($"Not found: {fullPath}");
return false;
}
// Optional signature validation
if (!ValidateSignatureIfRequired(fullPath, requireSigned, certValidator, logger))
{
// If requireSigned=false we never reach here (it would return true).
logger.Warn($"Signature validation failed: {fullPath}");
return false;
}
string directoryPath = Path.GetDirectoryName(fullPath) ?? AppContext.BaseDirectory;
if (!TryRegisterSearchDirectory(directoryPath, logger))
{
logger.Warn($"Could not register search directory: {directoryPath}");
}
logger.Info($"Loading: {fullPath}");
IntPtr handle = LoadLibrary(fullPath);
if (handle == IntPtr.Zero)
{
int err = Marshal.GetLastWin32Error();
logger.Warn($"LoadLibrary failed for {fullPath} (Win32Error={err}).");
return false;
}
logger.Info($"Successfully loaded: {fullPath}");
return true;
}
catch (Exception ex)
{
logger.Warn($"Exception while loading {fullPath}: {ex.Message}");
return false;
}
}
/// <summary>
/// If requireSigned=false, returns true (no check).
/// If requireSigned=true, verifies Authenticode chain and optional custom validator.
/// </summary>
private static bool ValidateSignatureIfRequired(
string path,
bool requireSigned,
Func<X509Certificate2, bool> certValidator,
ICompatibleLogger logger)
{
if (!requireSigned)
{
// No signature requirement: allow loading regardless of signature.
return true;
}
try
{
// Throws on unsigned files.
var baseCert = X509Certificate.CreateFromSignedFile(path);
if (baseCert == null)
{
logger.Warn("No certificate extracted from file.");
return false;
}
var cert = new X509Certificate2(baseCert);
var chain = new X509Chain
{
ChainPolicy =
{
RevocationMode = X509RevocationMode.Online,
RevocationFlag = X509RevocationFlag.ExcludeRoot,
VerificationFlags = X509VerificationFlags.NoFlag,
VerificationTime = DateTime.UtcNow
}
};
bool chainOk = chain.Build(cert);
if (!chainOk)
{
foreach (var status in chain.ChainStatus)
logger.Warn($"Cert chain status: {status.Status} - {status.StatusInformation?.Trim()}");
return false;
}
// Optional extra validation, e.g. thumbprint or subject pinning.
if (certValidator != null)
{
bool ok = false;
try { ok = certValidator(cert); }
catch (Exception ex)
{
logger.Warn($"Custom certificate validator threw: {ex.Message}");
return false;
}
if (!ok)
{
logger.Warn("Custom certificate validator rejected the certificate.");
return false;
}
}
logger.Info($"Signature validated. Subject='{cert.Subject}', Thumbprint={cert.Thumbprint}");
return true;
}
catch (Exception ex)
{
logger.Warn($"Signature check failed: {ex.Message}");
return false;
}
}
private static bool TryRegisterSearchDirectory(string directoryPath, ICompatibleLogger logger)
{
try
{
bool ok = SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_DEFAULT_DIRS);
if (!ok)
{
int e = Marshal.GetLastWin32Error();
logger.Warn($"SetDefaultDllDirectories failed (Win32Error={e}), fallback to SetDllDirectory.");
return SetDllDirectory(directoryPath);
}
IntPtr cookie = AddDllDirectory(directoryPath);
if (cookie == IntPtr.Zero)
{
int e = Marshal.GetLastWin32Error();
logger.Warn($"AddDllDirectory failed (Win32Error={e}), fallback to SetDllDirectory.");
return SetDllDirectory(directoryPath);
}
logger.Info($"Registered native DLL search directory: {directoryPath}");
return true;
}
catch (EntryPointNotFoundException)
{
logger.Warn("DefaultDllDirectories API not available. Using SetDllDirectory fallback.");
return SetDllDirectory(directoryPath);
}
catch (Exception ex)
{
logger.Warn($"Register search directory failed for {directoryPath}: {ex.Message}");
return false;
}
}
}
}

View File

@ -32,19 +32,10 @@ namespace WelsonJS.Launcher
// set up logger
_logger = new TraceLogger();
// load native libraries
string appDataSubDirectory = "WelsonJS";
bool requireSigned = string.Equals(
GetAppConfig("NativeRequireSigned"),
"true",
StringComparison.OrdinalIgnoreCase);
NativeBootstrap.Init(
dllNames: new[] { "ChakraCore.dll" },
appDataSubdirectory: appDataSubDirectory,
logger: _logger,
requireSigned: requireSigned
);
// load external assemblies
AssemblyLoader.BaseUrl = GetAppConfig("AssemblyBaseUrl");
AssemblyLoader.Register();
AssemblyLoader.LoadNativeModules("ChakraCore", new Version(1, 13, 0, 0), new[] { "ChakraCore.dll" });
// telemetry
try

View File

@ -60,6 +60,15 @@ namespace WelsonJS.Launcher.Properties {
}
}
/// <summary>
/// https://catswords.blob.core.windows.net/welsonjs/packages과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>
internal static string AssemblyBaseUrl {
get {
return ResourceManager.GetString("AssemblyBaseUrl", resourceCulture);
}
}
/// <summary>
/// 과(와) 유사한 지역화된 문자열을 찾습니다.
/// </summary>

View File

@ -241,4 +241,7 @@
<data name="TelemetryEnabled" xml:space="preserve">
<value>true</value>
</data>
<data name="AssemblyBaseUrl" xml:space="preserve">
<value>https://catswords.blob.core.windows.net/welsonjs/packages</value>
</data>
</root>

View File

@ -88,12 +88,12 @@
<Reference Include="System.Xml.Linq" />
</ItemGroup>
<ItemGroup>
<Compile Include="AssemblyLoader.cs" />
<Compile Include="ICompatibleLogger.cs" />
<Compile Include="IResourceTool.cs" />
<Compile Include="JsCore.cs" />
<Compile Include="JsNative.cs" />
<Compile Include="JsSerializer.cs" />
<Compile Include="NativeBootstrap.cs" />
<Compile Include="ResourceTools\ImageColorPicker.cs" />
<Compile Include="ResourceTools\IpQuery.cs" />
<Compile Include="ResourceTools\Settings.cs" />

View File

@ -30,6 +30,7 @@
<add key="TelemetryApiKey" value="phc_pmRHJ0aVEhtULRT4ilexwCjYpGtE9VYRhlA05fwiYt8"/>
<add key="TelemetryBaseUrl" value="https://us.i.posthog.com"/>
<add key="TelemetryEnabled" value="true"/>
<add key="AssemblyBaseUrl" value="https://catswords.blob.core.windows.net/welsonjs/packages"/>
</appSettings>
<startup>
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.7.2"/>