Editor/Bridge/SecboxCoreLoader.cs
using System;
using System.IO;
using System.Net.Http;
using System.Reflection;
using System.Runtime.Loader;
using System.Security.Cryptography;
using System.Threading.Tasks;
namespace Sandbox.SecBox.Bridge;
// Downloads + SHA-256-verifies + AssemblyLoadContext-loads Secbox.Core.dll
// (and its dependency DLLs). Caches under CorePolicy.LocalCachePath so we
// only network once per version. Refuses to load any blob whose SHA-256
// doesn't match the value pinned in CorePolicy.
//
// Dev override: setting %SECBOX_DEV_PATH% to a folder skips download/verify
// and loads directly from that path. Intended for local iteration only.
public static class SecboxCoreLoader
{
const string LoadContextName = "secbox-core";
static Assembly _coreAssembly;
static AssemblyLoadContext _alc;
public static Assembly CoreAssembly => _coreAssembly;
public static async Task<Assembly> EnsureLoadedAsync()
{
if (_coreAssembly != null) return _coreAssembly;
DiagnosticsLog.Trace("SecboxCoreLoader.EnsureLoadedAsync: begin");
string folder;
var dev = CorePolicy.DevOverridePath;
if (!string.IsNullOrEmpty(dev) && Directory.Exists(dev))
{
DiagnosticsLog.Warn($"DEV MODE: loading core from {dev} (hash verification skipped)");
folder = dev;
}
else
{
folder = CorePolicy.LocalCachePath;
DiagnosticsLog.Trace($"production load path; cache folder = {folder}");
try
{
await EnsureCachedAsync(folder);
}
catch (Exception ex)
{
DiagnosticsLog.Error("EnsureCachedAsync failed", ex);
throw;
}
}
try
{
_alc = new AssemblyLoadContext(LoadContextName, isCollectible: true);
_alc.Resolving += (ctx, name) =>
{
var candidate = Path.Combine(folder, name.Name + ".dll");
DiagnosticsLog.Trace($"ALC.Resolving: {name.Name} → {(File.Exists(candidate) ? "found" : "missing")} ({candidate})");
// 0Harmony (+ bundled MonoMod) MUST be a SINGLE instance in the
// Default, non-collectible context. If this collectible ALC loads
// its own copy, Core binds to it while Harmony's MonoMod detours
// (emitted into Default) bind to a separate Default copy - the two
// type identities collide and every patch throws "CecilILGenerator
// … violates the constraint of TTarget" (0 methods patched). Hand
// 0Harmony/MonoMod to Default so Core + the detours share one copy.
var shared = name.Name ?? "";
bool toDefault = File.Exists(candidate) && (string.Equals(shared, "0Harmony", StringComparison.OrdinalIgnoreCase) || shared.StartsWith("MonoMod", StringComparison.OrdinalIgnoreCase));
if (toDefault)
{
DiagnosticsLog.Trace($"ALC.Resolving: {shared} → Default ALC (shared single-instance)");
return AssemblyLoadContext.Default.LoadFromAssemblyPath(candidate);
}
return File.Exists(candidate) ? ctx.LoadFromAssemblyPath(candidate) : null;
};
var corePath = Path.Combine(folder, "Secbox.Core.dll");
if (!File.Exists(corePath))
{
DiagnosticsLog.Error($"Secbox.Core.dll missing at {corePath}");
throw new FileNotFoundException("Secbox.Core.dll not found.", corePath);
}
DiagnosticsLog.Trace($"loading Secbox.Core from {corePath}");
_coreAssembly = _alc.LoadFromAssemblyPath(corePath);
DiagnosticsLog.Info($"loaded {_coreAssembly.GetName().Name} v{_coreAssembly.GetName().Version}");
return _coreAssembly;
}
catch (Exception ex)
{
DiagnosticsLog.Error("ALC load failed", ex);
throw;
}
}
// Unload the ALC so a fresh Secbox.Core can be loaded after an update.
// Returns true if collection was triggered; the unload happens
// asynchronously and only completes when no managed references remain.
public static bool TryUnload()
{
if (_alc == null) return false;
_alc.Unload();
_alc = null;
_coreAssembly = null;
GC.Collect();
GC.WaitForPendingFinalizers();
return true;
}
static async Task EnsureCachedAsync(string folder)
{
Directory.CreateDirectory(folder);
using var http = new HttpClient();
http.DefaultRequestHeaders.UserAgent.ParseAdd($"secbox-adapter/{CorePolicy.CoreVersion}");
http.Timeout = TimeSpan.FromSeconds(60); // refuse to hang on slow CDN
foreach (var (fileName, expectedHash) in CorePolicy.CoreFiles)
{
var destPath = Path.Combine(folder, fileName);
if (File.Exists(destPath) && Sha256OfFile(destPath) == expectedHash)
{
DiagnosticsLog.Trace($"cache hit for {fileName}");
continue;
}
if (!CorePolicy.AutoUpdate)
{
DiagnosticsLog.Error($"cached {fileName} missing or hash mismatch, AutoUpdate off");
throw new InvalidOperationException(
$"Cached {fileName} missing or hash mismatch, and AutoUpdate is off.");
}
var url = CorePolicy.BaseUrl.TrimEnd('/') + "/" + CorePolicy.CoreVersion + "/" + fileName;
DiagnosticsLog.Info($"downloading {fileName} from {url}");
byte[] bytes;
try
{
bytes = await http.GetByteArrayAsync(url);
}
catch (Exception ex)
{
DiagnosticsLog.Error($"download failed for {fileName}", ex);
throw new InvalidOperationException(
$"Failed to download {fileName}: {ex.Message}", ex);
}
var actualHash = Sha256OfBytes(bytes);
if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
{
DiagnosticsLog.Error($"SHA-256 mismatch for {fileName}: expected {expectedHash} got {actualHash}");
throw new InvalidOperationException(
$"SHA-256 mismatch for {fileName}. Expected {expectedHash}, got {actualHash}. "
+ $"Refusing to load - possible tampering or stale adapter.");
}
await File.WriteAllBytesAsync(destPath, bytes);
DiagnosticsLog.Trace($"wrote verified {fileName} ({bytes.Length} bytes)");
}
}
static string Sha256OfFile(string path)
{
using var sha = SHA256.Create();
using var fs = File.OpenRead(path);
return ToHex(sha.ComputeHash(fs));
}
static string Sha256OfBytes(byte[] bytes)
{
using var sha = SHA256.Create();
return ToHex(sha.ComputeHash(bytes));
}
static string ToHex(byte[] bytes)
{
var sb = new System.Text.StringBuilder(bytes.Length * 2);
for (int i = 0; i < bytes.Length; i++) sb.Append(bytes[i].ToString("x2"));
return sb.ToString();
}
}