Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of RunicSafety v1.0.0
RunicSafety.dll
Decompiled 9 hours ago
The result has been truncated due to the large size, download it to view full contents!
using System; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Threading; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using RunicSafety.Api; using RunicSafety.Integration; using RunicSafety.Services; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: AssemblyTitle("Runic Safety")] [assembly: AssemblyDescription("Loss-prevention, compatibility, recovery planning, and migration safeguards for Valheim.")] [assembly: AssemblyCompany("Chazman")] [assembly: AssemblyProduct("Runic Safety")] [assembly: AssemblyCopyright("Copyright © 2026 Chazman")] [assembly: ComVisible(false)] [assembly: Guid("8f1fa4b8-7f91-46cc-94a4-7b769e1af73d")] [assembly: AssemblyFileVersion("1.0.0.0")] [assembly: AssemblyInformationalVersion("1.0.0")] [assembly: InternalsVisibleTo("RunicSafety.Tests")] [assembly: TargetFramework(".NETStandard,Version=v2.1", FrameworkDisplayName = ".NET Standard 2.1")] [assembly: AssemblyVersion("1.0.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace RunicSafety { [BepInPlugin("chazman.RunicSafety", "Runic Safety", "1.0.0")] public sealed class Plugin : BaseUnityPlugin { public const string Guid = "chazman.RunicSafety"; public const string Name = "Runic Safety"; public const string Version = "1.0.0"; public const string ModuleId = "runic.safety"; public const string ProtocolVersion = "1.0"; private Harmony _harmony; private CorrelatedDiagnosticBuffer _diagnostics; private bool _configurationSubscribed; private bool _shuttingDown; internal static bool RuntimeReady { get; private set; } internal static SafetyRuntime CurrentRuntime { get; private set; } private void Awake() { //IL_0074: Unknown result type (might be due to invalid IL or missing references) //IL_007e: Expected O, but got Unknown SafetyConfig.Bind(((BaseUnityPlugin)this).Config); SafetyConfig.Changed += OnConfigurationChanged; _configurationSubscribed = true; _diagnostics = new CorrelatedDiagnosticBuffer(256, null, ((BaseUnityPlugin)this).Logger); try { if (!ValheimContracts.Initialize(out var problem)) { throw new MissingMethodException(problem); } CurrentRuntime = new SafetyRuntime(_diagnostics); CurrentRuntime.Initialize(); SafetyIntegrationApi.Attach(CurrentRuntime); _harmony = new Harmony("chazman.RunicSafety"); _harmony.PatchAll(typeof(Plugin).Assembly); RuntimeReady = true; ((BaseUnityPlugin)this).Logger.LogInfo((object)"Runic Safety v1.0.0 ready for Valheim 0.221.12. Confirmations, protected destinations, vanilla tombstone audits, and migration backups are standalone."); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("Runic Safety startup failed closed; all patched actions remain vanilla. " + ex.GetType().Name + ": " + ex.Message)); ShutdownRuntime(); } } private void OnConfigurationChanged() { if (!RuntimeReady || CurrentRuntime == null) { return; } try { CurrentRuntime.OnConfigurationChanged(); } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogError((object)("Safety configuration refresh failed; the next action will use bounded defaults. " + ex.GetType().Name + ": " + ex.Message)); } } private void OnDestroy() { ShutdownRuntime(); if (_configurationSubscribed) { SafetyConfig.Changed -= OnConfigurationChanged; _configurationSubscribed = false; } SafetyConfig.Unbind(); _diagnostics?.SetLog(null); _diagnostics = null; } private void ShutdownRuntime() { if (_shuttingDown) { return; } _shuttingDown = true; RuntimeReady = false; try { Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } } catch (Exception ex) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Safety patch cleanup failed: " + ex.Message)); } _harmony = null; SafetyRuntime currentRuntime = CurrentRuntime; SafetyIntegrationApi.Detach(currentRuntime); try { currentRuntime?.Shutdown(); } catch (Exception ex2) { ((BaseUnityPlugin)this).Logger.LogWarning((object)("Safety runtime cleanup failed: " + ex2.Message)); } CurrentRuntime = null; _shuttingDown = false; } } internal static class SafetyConfig { private const string DefaultRarePrefabs = "DragonEgg,DvergrKey,DvergrKeyFragment,QueenDrop,Sealbreaker,Wishbone,TrophyTheQueen,TrophySeekerQueen"; private static readonly object Sync = new object(); private static HashSet<string> _rarePrefabs = new HashSet<string>(StringComparer.Ordinal); private static ConfigFile _config; internal static ConfigEntry<bool> Enabled { get; private set; } internal static ConfigEntry<bool> ConfirmRareSacrifice { get; private set; } internal static ConfigEntry<bool> ConfirmOccupiedContainer { get; private set; } internal static ConfigEntry<bool> ConfirmVehicleDestruction { get; private set; } internal static ConfigEntry<bool> ConfirmPortalOverwrite { get; private set; } internal static ConfigEntry<float> ConfirmationWindowSeconds { get; private set; } internal static ConfigEntry<bool> ProtectedDestinations { get; private set; } internal static ConfigEntry<bool> AdministratorBypass { get; private set; } internal static ConfigEntry<string> RarePrefabNames { get; private set; } internal static ConfigEntry<string> BackupRoot { get; private set; } internal static ConfigEntry<int> BackupRetention { get; private set; } internal static ConfigEntry<int> BackupMaximumFiles { get; private set; } internal static ConfigEntry<int> BackupMaximumMiB { get; private set; } internal static TimeSpan ConfirmationWindow => TimeSpan.FromSeconds(ConfirmationWindowSeconds?.Value ?? 4f); internal static event Action Changed; internal static void Bind(ConfigFile config) { //IL_00c9: Unknown result type (might be due to invalid IL or missing references) //IL_00d3: Expected O, but got Unknown //IL_017c: Unknown result type (might be due to invalid IL or missing references) //IL_0186: Expected O, but got Unknown //IL_01ad: Unknown result type (might be due to invalid IL or missing references) //IL_01b7: Expected O, but got Unknown //IL_01e1: Unknown result type (might be due to invalid IL or missing references) //IL_01eb: Expected O, but got Unknown if (config == null) { throw new ArgumentNullException("config"); } Unbind(); _config = config; Enabled = Bind(config, "General", "Enabled", value: true, "Master runtime gate. False preserves vanilla behavior while public audit/backup services remain discoverable."); ConfirmRareSacrifice = Bind(config, "Confirmations", "RareItemSacrifice", value: true, "Require the same rare-item sacrifice action twice inside the confirmation window."); ConfirmOccupiedContainer = Bind(config, "Confirmations", "OccupiedContainerDestruction", value: true, "Require a second hammer removal for a piece containing a non-empty container."); ConfirmVehicleDestruction = Bind(config, "Confirmations", "ShipOrCartDestruction", value: true, "Require a second hammer removal for a ship or cart piece."); ConfirmPortalOverwrite = Bind(config, "Confirmations", "PortalOverwrite", value: true, "Require a second commit when replacing an existing portal tag with a different tag."); ConfirmationWindowSeconds = Bind(config, "Confirmations", "RepeatWindowSeconds", 4f, new ConfigDescription("Seconds allowed for the identical repeat action.", (AcceptableValueBase)(object)new AcceptableValueRange<float>(1f, 15f), Array.Empty<object>())); ProtectedDestinations = Bind(config, "Protected Items", "Enabled", value: true, "Apply equipped, quest, lock-provider, and configured-rare policy at audited vanilla destination boundaries."); AdministratorBypass = Bind(config, "Protected Items", "AdministratorBypass", value: false, "Allow a verified host/admin to bypass protected-item policy. Every bypass is recorded without item contents."); RarePrefabNames = Bind(config, "Protected Items", "RarePrefabNames", "DragonEgg,DvergrKey,DvergrKeyFragment,QueenDrop,Sealbreaker,Wishbone,TrophyTheQueen,TrophySeekerQueen", "Comma/semicolon separated exact prefab names. At most 256 bounded entries are accepted."); BackupRoot = Bind(config, "Migration Backups", "RootDirectory", Path.Combine(Paths.ConfigPath, "RunicSafety", "backups"), "Default same-volume root offered to migration clients. Safety only removes marked backups within this root."); BackupRetention = Bind(config, "Migration Backups", "RetentionCount", 5, new ConfigDescription("Committed backups retained after a successful new commit.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 128), Array.Empty<object>())); BackupMaximumFiles = Bind(config, "Migration Backups", "MaximumFiles", 32, new ConfigDescription("Maximum source files in one backup transaction.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 1024), Array.Empty<object>())); BackupMaximumMiB = Bind(config, "Migration Backups", "MaximumTotalMiB", 2048, new ConfigDescription("Maximum total source bytes in one backup transaction.", (AcceptableValueBase)(object)new AcceptableValueRange<int>(1, 65536), Array.Empty<object>())); RebuildRarePrefabs(); config.SettingChanged += OnSettingChanged; } internal static void Unbind() { if (_config != null) { _config.SettingChanged -= OnSettingChanged; } _config = null; lock (Sync) { _rarePrefabs = new HashSet<string>(StringComparer.Ordinal); } } internal static bool IsRarePrefab(string prefabName) { if (string.IsNullOrEmpty(prefabName)) { return false; } lock (Sync) { return _rarePrefabs.Contains(prefabName); } } internal static string SynchronizedRulesHash() { string[] value; lock (Sync) { value = new List<string>(_rarePrefabs).OrderBy<string, string>((string result) => result, StringComparer.Ordinal).ToArray(); } string s = string.Join("|", (Enabled?.Value ?? true).ToString(CultureInfo.InvariantCulture), (ConfirmRareSacrifice?.Value ?? true).ToString(CultureInfo.InvariantCulture), (ConfirmOccupiedContainer?.Value ?? true).ToString(CultureInfo.InvariantCulture), (ConfirmVehicleDestruction?.Value ?? true).ToString(CultureInfo.InvariantCulture), (ConfirmPortalOverwrite?.Value ?? true).ToString(CultureInfo.InvariantCulture), (ConfirmationWindowSeconds?.Value ?? 4f).ToString("R", CultureInfo.InvariantCulture), (ProtectedDestinations?.Value ?? true).ToString(CultureInfo.InvariantCulture), (AdministratorBypass?.Value ?? false).ToString(CultureInfo.InvariantCulture), string.Join(",", value)); using SHA256 sHA = SHA256.Create(); byte[] array = sHA.ComputeHash(Encoding.UTF8.GetBytes(s)); StringBuilder stringBuilder = new StringBuilder(array.Length * 2); byte[] array2 = array; foreach (byte b in array2) { stringBuilder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); } return stringBuilder.ToString(); } private static ConfigEntry<T> Bind<T>(ConfigFile config, string section, string key, T value, string description) { //IL_000c: Unknown result type (might be due to invalid IL or missing references) //IL_0016: Expected O, but got Unknown return Bind(config, section, key, value, new ConfigDescription(description, (AcceptableValueBase)null, Array.Empty<object>())); } private static ConfigEntry<T> Bind<T>(ConfigFile config, string section, string key, T value, ConfigDescription description) { return config.Bind<T>(section, key, value, description); } private static void OnSettingChanged(object sender, EventArgs eventArgs) { RebuildRarePrefabs(); SafetyConfig.Changed?.Invoke(); } private static void RebuildRarePrefabs() { string obj = RarePrefabNames?.Value ?? "DragonEgg,DvergrKey,DvergrKeyFragment,QueenDrop,Sealbreaker,Wishbone,TrophyTheQueen,TrophySeekerQueen"; HashSet<string> hashSet = new HashSet<string>(StringComparer.Ordinal); string[] array = obj.Split(new char[2] { ',', ';' }, StringSplitOptions.RemoveEmptyEntries); for (int i = 0; i < array.Length; i++) { if (hashSet.Count >= 256) { break; } string text = array[i].Trim(); if (text.Length != 0 && text.Length <= 96 && IsSafePrefabName(text)) { hashSet.Add(text); } } lock (Sync) { _rarePrefabs = hashSet; } } private static bool IsSafePrefabName(string value) { foreach (char c in value) { if (!char.IsLetterOrDigit(c) && c != '_' && c != '-') { return false; } } return true; } } } namespace RunicSafety.Services { public sealed class CompatibilityGate : ICompatibilityGate { private readonly ISafetyDiagnosticService _diagnostics; private Func<bool> _remoteAdmissionAvailable; public bool RemoteAdmissionHookAvailable { get { try { return _remoteAdmissionAvailable(); } catch (Exception) { return false; } } } public CompatibilityGate(ISafetyDiagnosticService diagnostics) { _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); _remoteAdmissionAvailable = () => false; } internal void SetRemoteAdmissionAvailability(Func<bool> available) { _remoteAdmissionAvailable = available ?? ((Func<bool>)(() => false)); } public CompatibilityDecision Evaluate(CompatibilityIdentity local, CompatibilityIdentity remote, IEnumerable<KnownUnsafeCombination> knownUnsafe = null) { string correlation = _diagnostics.NewCorrelationId("compat"); if (!Valid(local) || !Valid(remote)) { return Result(CompatibilityOutcome.BlockedInvalidIdentity, "supply-complete-compatibility-metadata", correlation); } if (!string.Equals(local.GameVersion, remote.GameVersion, StringComparison.Ordinal)) { return Result(CompatibilityOutcome.BlockedGameVersion, "match-valheim-versions", correlation); } if (!TryReadMajor(local.ProtocolVersion, out var major) || !TryReadMajor(remote.ProtocolVersion, out var major2)) { return Result(CompatibilityOutcome.BlockedProtocol, "install-compatible-runic-safety-version", correlation); } if (major != major2) { return Result(CompatibilityOutcome.BlockedProtocol, "install-compatible-runic-safety-version", correlation); } if (!string.Equals(local.TopologyHash, remote.TopologyHash, StringComparison.Ordinal)) { return Result(CompatibilityOutcome.BlockedTopology, "match-inventory-slot-topology", correlation); } if (!string.Equals(local.SynchronizedRulesHash, remote.SynchronizedRulesHash, StringComparison.Ordinal)) { return Result(CompatibilityOutcome.BlockedSynchronizedRules, "match-server-safety-rules", correlation); } if (knownUnsafe != null) { int num = 0; foreach (KnownUnsafeCombination item in knownUnsafe) { if (++num > 128) { return Result(CompatibilityOutcome.BlockedInvalidIdentity, "reduce-known-combination-list", correlation); } if (item != null) { bool num2 = Matches(local, remote, item); bool flag = Matches(remote, local, item); if (num2 || flag) { return Result(CompatibilityOutcome.BlockedKnownCombination, Bound(item.RemediationCode, "remove-known-unsafe-combination"), correlation); } } } } return Result(CompatibilityOutcome.Compatible, "none", correlation); } private CompatibilityDecision Result(CompatibilityOutcome outcome, string remediation, string correlation) { _diagnostics.Record(correlation, "compatibility", outcome.ToString().ToLowerInvariant(), (outcome != CompatibilityOutcome.Compatible) ? SafetyDiagnosticSeverity.Warning : SafetyDiagnosticSeverity.Information); return new CompatibilityDecision(outcome, remediation, correlation); } private static bool Valid(CompatibilityIdentity identity) { if (identity == null || string.IsNullOrWhiteSpace(identity.ModuleId) || string.IsNullOrWhiteSpace(identity.SemanticVersion) || string.IsNullOrWhiteSpace(identity.ProtocolVersion) || string.IsNullOrWhiteSpace(identity.GameVersion) || identity.ModuleId.Length > 96 || identity.TopologyHash.Length > 128 || identity.SynchronizedRulesHash.Length > 128) { return false; } Version result; return Version.TryParse(identity.SemanticVersion.Split(new char[2] { '-', '+' }, 2)[0], out result); } private static bool Matches(CompatibilityIdentity local, CompatibilityIdentity remote, KnownUnsafeCombination candidate) { if (string.Equals(local.ModuleId, candidate.LocalModuleId, StringComparison.Ordinal) && string.Equals(local.SemanticVersion, candidate.LocalVersion, StringComparison.Ordinal) && string.Equals(remote.ModuleId, candidate.RemoteModuleId, StringComparison.Ordinal)) { return string.Equals(remote.SemanticVersion, candidate.RemoteVersion, StringComparison.Ordinal); } return false; } private static string Bound(string value, string fallback) { if (string.IsNullOrWhiteSpace(value)) { return fallback; } string text = value.Trim(); if (text.Length > 64) { return text.Substring(0, 64); } return text; } private static bool TryReadMajor(string value, out int major) { major = 0; if (string.IsNullOrWhiteSpace(value)) { return false; } string[] array = value.Trim().Split('.'); if (array.Length >= 1 && int.TryParse(array[0], out major)) { return major >= 0; } return false; } } public sealed class ContextualConfirmationService : IContextualConfirmationService { private readonly struct PendingEntry { internal string Fingerprint { get; } internal DateTime ExpiresUtc { get; } internal DateTime CreatedUtc { get; } internal PendingEntry(string fingerprint, DateTime expiresUtc, DateTime createdUtc) { Fingerprint = fingerprint; ExpiresUtc = expiresUtc; CreatedUtc = createdUtc; } } public const int DefaultCapacity = 128; private const int MaximumContextLength = 160; private const int MaximumFingerprintLength = 128; private readonly object _sync = new object(); private readonly Dictionary<string, PendingEntry> _pending; private readonly ISafetyDiagnosticService _diagnostics; public int Capacity { get; } public int PendingCount { get { lock (_sync) { return _pending.Count; } } } public ContextualConfirmationService(ISafetyDiagnosticService diagnostics, int capacity = 128) { if (capacity < 8 || capacity > 1024) { throw new ArgumentOutOfRangeException("capacity"); } _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); Capacity = capacity; _pending = new Dictionary<string, PendingEntry>(StringComparer.Ordinal); } public ConfirmationDecision Evaluate(ConfirmationRequest request, DateTime utcNow) { string correlationId = _diagnostics.NewCorrelationId("confirm"); if (request == null || string.IsNullOrWhiteSpace(request.ContextKey) || request.ContextKey.Length > 160 || request.StateFingerprint.Length > 128 || request.Window < TimeSpan.FromMilliseconds(250.0) || request.Window > TimeSpan.FromSeconds(30.0)) { _diagnostics.Record(correlationId, "confirmation", "invalid-request", SafetyDiagnosticSeverity.Warning); return new ConfirmationDecision(ConfirmationOutcome.Invalid, correlationId); } if (!request.Enabled) { _diagnostics.Record(correlationId, "confirmation", "disabled-proceed"); return new ConfirmationDecision(ConfirmationOutcome.Proceed, correlationId); } DateTime dateTime = ((utcNow.Kind == DateTimeKind.Utc) ? utcNow : utcNow.ToUniversalTime()); string key = ((int)request.Action).ToString(CultureInfo.InvariantCulture) + ":" + request.ContextKey; lock (_sync) { RemoveExpiredLocked(dateTime); if (_pending.TryGetValue(key, out var value) && value.ExpiresUtc >= dateTime && string.Equals(value.Fingerprint, request.StateFingerprint, StringComparison.Ordinal)) { _pending.Remove(key); _diagnostics.Record(correlationId, "confirmation", "confirmed"); return new ConfirmationDecision(ConfirmationOutcome.Proceed, correlationId); } if (_pending.Count >= Capacity) { EvictOldestLocked(); } _pending[key] = new PendingEntry(request.StateFingerprint, dateTime + request.Window, dateTime); } _diagnostics.Record(correlationId, "confirmation", "repeat-required"); return new ConfirmationDecision(ConfirmationOutcome.ConfirmAgain, correlationId); } public void Cancel(string contextKey) { if (string.IsNullOrEmpty(contextKey)) { return; } lock (_sync) { List<string> list = new List<string>(); foreach (string key in _pending.Keys) { if (key.EndsWith(":" + contextKey, StringComparison.Ordinal)) { list.Add(key); } } foreach (string item in list) { _pending.Remove(item); } } } public void Clear() { lock (_sync) { _pending.Clear(); } } private void RemoveExpiredLocked(DateTime now) { if (_pending.Count == 0) { return; } List<string> list = new List<string>(); foreach (KeyValuePair<string, PendingEntry> item in _pending) { if (item.Value.ExpiresUtc < now) { list.Add(item.Key); } } foreach (string item2 in list) { _pending.Remove(item2); } } private void EvictOldestLocked() { string text = null; DateTime dateTime = DateTime.MaxValue; foreach (KeyValuePair<string, PendingEntry> item in _pending) { if (!(item.Value.CreatedUtc >= dateTime)) { text = item.Key; dateTime = item.Value.CreatedUtc; } } if (text != null) { _pending.Remove(text); } } } public sealed class CorrelatedDiagnosticBuffer : ISafetyDiagnosticService { public const int DefaultCapacity = 256; private const int MaximumTokenLength = 64; private readonly object _sync = new object(); private readonly SafetyDiagnosticEvent[] _events; private readonly Func<DateTime> _clock; private ManualLogSource _log; private int _start; private int _count; private long _sequence; public int Capacity => _events.Length; public int Count { get { lock (_sync) { return _count; } } } public CorrelatedDiagnosticBuffer(int capacity = 256, Func<DateTime> clock = null, ManualLogSource log = null) { if (capacity < 8 || capacity > 4096) { throw new ArgumentOutOfRangeException("capacity"); } _events = new SafetyDiagnosticEvent[capacity]; _clock = clock ?? ((Func<DateTime>)(() => DateTime.UtcNow)); _log = log; } internal void SetLog(ManualLogSource log) { lock (_sync) { _log = log; } } public string NewCorrelationId(string category) { string text = Normalize(category, "transaction"); long num; lock (_sync) { num = NextSequenceLocked(); } return text + "-" + num.ToString("x16", CultureInfo.InvariantCulture); } public void Record(string correlationId, string category, string code, SafetyDiagnosticSeverity severity = SafetyDiagnosticSeverity.Information) { string correlationId2 = Normalize(correlationId, "uncorrelated"); string category2 = Normalize(category, "unknown"); string code2 = Normalize(code, "unspecified"); SafetyDiagnosticEvent safetyDiagnosticEvent; ManualLogSource log; lock (_sync) { safetyDiagnosticEvent = new SafetyDiagnosticEvent(NextSequenceLocked(), _clock().ToUniversalTime(), correlationId2, category2, code2, severity); int num = (_start + _count) % _events.Length; if (_count == _events.Length) { num = _start; _start = (_start + 1) % _events.Length; } else { _count++; } _events[num] = safetyDiagnosticEvent; log = _log; } if (log != null) { string text = "[" + safetyDiagnosticEvent.CorrelationId + "] " + safetyDiagnosticEvent.Category + "/" + safetyDiagnosticEvent.Code; switch (severity) { case SafetyDiagnosticSeverity.Error: log.LogError((object)text); break; case SafetyDiagnosticSeverity.Warning: log.LogWarning((object)text); break; default: log.LogInfo((object)text); break; } } } public IReadOnlyList<SafetyDiagnosticEvent> Snapshot() { lock (_sync) { List<SafetyDiagnosticEvent> list = new List<SafetyDiagnosticEvent>(_count); for (int i = 0; i < _count; i++) { list.Add(_events[(_start + i) % _events.Length]); } return list.AsReadOnly(); } } private long NextSequenceLocked() { if (_sequence == long.MaxValue) { _sequence = 0L; } return ++_sequence; } private static string Normalize(string value, string fallback) { if (string.IsNullOrWhiteSpace(value)) { return fallback; } string text = value.Trim(); int num = Math.Min(text.Length, 64); char[] array = new char[num]; for (int i = 0; i < num; i++) { char c = text[i]; array[i] = ((char.IsLetterOrDigit(c) || c == '-' || c == '_' || c == '.') ? c : '_'); } return new string(array); } } internal static class InventoryProtectionAdapter { private const string PluginGuid = "chazman.RunicInventory"; private const string ApiTypeName = "RunicInventory.Api.InventoryIntegrationApi"; private static MethodInfo _method; private static object[] _arguments; private static bool _resolved; internal static ItemLockState Resolve(object nativeItem, out bool integrationPresent) { integrationPresent = Chainloader.PluginInfos.ContainsKey("chazman.RunicInventory"); if (!integrationPresent) { return ItemLockState.NotApplicable; } if (nativeItem == null || !TryResolveMethod()) { return ItemLockState.Unknown; } try { _arguments[0] = nativeItem; _arguments[1] = 0; object obj = _method.Invoke(null, _arguments); bool flag = default(bool); int num; if (obj is bool) { flag = (bool)obj; num = 1; } else { num = 0; } int num2 = num & (flag ? 1 : 0); int num3 = ((_arguments[1] is int num4) ? num4 : 0); if (num2 == 0) { return ItemLockState.NotApplicable; } int result; switch (num3) { case 1: return ItemLockState.Unlocked; default: result = 3; break; case 2: result = 2; break; } return (ItemLockState)result; } catch { _resolved = false; _method = null; _arguments = null; return ItemLockState.Unknown; } finally { if (_arguments != null) { _arguments[0] = null; _arguments[1] = 0; } } } private static bool TryResolveMethod() { if (_resolved) { return _method != null; } _resolved = true; if (!Chainloader.PluginInfos.TryGetValue("chazman.RunicInventory", out var value)) { return false; } _method = ((value == null) ? null : ((object)value.Instance)?.GetType().Assembly.GetType("RunicInventory.Api.InventoryIntegrationApi", throwOnError: false))?.GetMethod("TryGetProtection", BindingFlags.Static | BindingFlags.Public, null, new Type[2] { typeof(object), typeof(int).MakeByRefType() }, null); if (_method == null || _method.ReturnType != typeof(bool)) { return false; } _arguments = new object[2] { null, 0 }; return true; } } public sealed class MigrationBackupService : IMigrationBackupService { private readonly struct SourceState { internal string Path { get; } internal string LogicalName { get; } internal BackupFileMetadata Metadata { get; } internal SourceState(string path, string logicalName, BackupFileMetadata metadata) { Path = path; LogicalName = logicalName; Metadata = metadata; } } private sealed class BackupSourceChangedException : IOException { } private readonly struct BackupDirectory { internal string Path { get; } internal DateTime CreatedUtc { get; } internal BackupDirectory(string path, DateTime createdUtc) { Path = path; CreatedUtc = createdUtc; } } private sealed class RootLease : IDisposable { private string _root; internal RootLease(string root) { _root = root; } public void Dispose() { ReleaseRoot(Interlocked.Exchange(ref _root, null)); } } internal const string MarkerFileName = ".runicsafety-backup"; internal const string ManifestFileName = "manifest.runic"; internal const string RestoreFileName = "RESTORE.txt"; private const string MarkerText = "RUNIC_SAFETY_BACKUP_V1"; private const int BufferSize = 81920; private const int AbsoluteMaximumFiles = 1024; private const long AbsoluteMaximumBytes = 68719476736L; private const long MaximumManifestBytes = 16777216L; private const long MaximumRestoreBytes = 1048576L; private const long MaximumMarkerBytes = 128L; private const int MaximumConcurrentBackupRoots = 128; private static readonly object RootGateSync = new object(); private static readonly StringComparer RootPathComparer = ((Path.DirectorySeparatorChar == '\\') ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal); private static readonly StringComparison RootPathComparison = ((Path.DirectorySeparatorChar == '\\') ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); private static readonly HashSet<string> ActiveBackupRoots = new HashSet<string>(RootPathComparer); private readonly IBackupStorage _storage; private readonly ISafetyDiagnosticService _diagnostics; private readonly Func<DateTime> _clock; private readonly Func<MigrationBackupDefaults> _defaults; public string DefaultDestinationRoot => _defaults().DestinationRoot; public int DefaultRetentionCount => _defaults().RetentionCount; public int DefaultMaximumFiles => _defaults().MaximumFiles; public long DefaultMaximumTotalBytes => _defaults().MaximumTotalBytes; public MigrationBackupService(ISafetyDiagnosticService diagnostics) { checked { this..ctor(new PhysicalBackupStorage(), diagnostics, () => DateTime.UtcNow, () => new MigrationBackupDefaults(SafetyConfig.BackupRoot?.Value ?? Path.Combine(Paths.ConfigPath, "RunicSafety", "backups"), SafetyConfig.BackupRetention?.Value ?? 5, SafetyConfig.BackupMaximumFiles?.Value ?? 32, unchecked((long)(SafetyConfig.BackupMaximumMiB?.Value ?? 2048)) * 1024L * 1024)); } } internal MigrationBackupService(IBackupStorage storage, ISafetyDiagnosticService diagnostics, Func<DateTime> clock, Func<MigrationBackupDefaults> defaults = null) { _storage = storage ?? throw new ArgumentNullException("storage"); _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); _clock = clock ?? throw new ArgumentNullException("clock"); _defaults = defaults ?? ((Func<MigrationBackupDefaults>)(() => new MigrationBackupDefaults(Path.Combine(Path.GetTempPath(), "RunicSafety", "backups"), 5, 32, 2147483648L))); } public MigrationBackupRequest CreateDefaultRequest(string migrationId, IEnumerable<MigrationBackupSource> sources) { MigrationBackupDefaults migrationBackupDefaults = _defaults(); return new MigrationBackupRequest(migrationId, sources, migrationBackupDefaults.DestinationRoot, migrationBackupDefaults.RetentionCount, migrationBackupDefaults.MaximumTotalBytes, migrationBackupDefaults.MaximumFiles); } public MigrationBackupResult CreateBackup(MigrationBackupRequest request, CancellationToken cancellationToken) { string correlation = _diagnostics.NewCorrelationId("backup"); if (!TryAcquireRootLease(request, correlation, out var canonicalRoot, out var lease, out var failure)) { return failure; } using (lease) { return CreateBackupUnderLease(request, cancellationToken, correlation, canonicalRoot); } } private MigrationBackupResult CreateBackupUnderLease(MigrationBackupRequest request, CancellationToken cancellationToken, string correlation, string canonicalRoot) { string root = string.Empty; string text = string.Empty; List<MigrationBackupFile> list = new List<MigrationBackupFile>(); try { if (!TryValidateRequest(request, out root, out var outcome, out var failureCode)) { return Failure(outcome, correlation, failureCode, list); } if (!SameRoot(root, canonicalRoot)) { return Failure(MigrationBackupOutcome.InvalidRequest, correlation, "backup-root-changed", list); } cancellationToken.ThrowIfCancellationRequested(); List<SourceState> list2 = new List<SourceState>(request.Sources.Count); long num = 0L; HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); for (int i = 0; i < request.Sources.Count; i++) { cancellationToken.ThrowIfCancellationRequested(); MigrationBackupSource migrationBackupSource = request.Sources[i]; if (migrationBackupSource == null || string.IsNullOrWhiteSpace(migrationBackupSource.SourcePath)) { return Failure(MigrationBackupOutcome.InvalidRequest, correlation, "source-path-required", list); } string fullPath = _storage.GetFullPath(migrationBackupSource.SourcePath); if (fullPath.Length > 4096) { return Failure(MigrationBackupOutcome.InvalidRequest, correlation, "source-path-too-long", list); } if (!_storage.FileExists(fullPath)) { return Failure(MigrationBackupOutcome.SourceMissing, correlation, "source-missing", list); } if (!hashSet.Add(fullPath)) { return Failure(MigrationBackupOutcome.InvalidRequest, correlation, "duplicate-source", list); } BackupFileMetadata fileMetadata = _storage.GetFileMetadata(fullPath); if (fileMetadata.IsReparsePoint) { return Failure(MigrationBackupOutcome.InvalidRequest, correlation, "reparse-source-refused", list); } try { num = checked(num + fileMetadata.Length); } catch (OverflowException) { return Failure(MigrationBackupOutcome.SizeLimitExceeded, correlation, "source-size-overflow", list); } if (num > request.MaximumTotalBytes) { return Failure(MigrationBackupOutcome.SizeLimitExceeded, correlation, "configured-size-limit", list); } list2.Add(new SourceState(fullPath, BoundLogicalName(migrationBackupSource.LogicalName, i), fileMetadata)); } _storage.CreateDirectory(root); if (_storage.IsDirectoryReparsePoint(root)) { return Failure(MigrationBackupOutcome.InvalidRequest, correlation, "reparse-backup-root-refused", list); } string text2 = _clock().ToUniversalTime().ToString("yyyyMMddTHHmmssfffffffZ", CultureInfo.InvariantCulture) + "-" + ShortCorrelation(correlation); text = _storage.Combine(root, ".partial-" + text2); string text3 = _storage.Combine(root, "backup-" + text2); if (_storage.DirectoryExists(text) || _storage.DirectoryExists(text3)) { return Failure(MigrationBackupOutcome.IoFailure, correlation, "backup-name-collision", list); } _storage.CreateDirectory(text); for (int j = 0; j < list2.Count; j++) { cancellationToken.ThrowIfCancellationRequested(); SourceState sourceState = list2[j]; string text4 = CreateBackupName(j, sourceState.Path); string text5 = _storage.Combine(text, text4); string sha; try { sha = CopyAndHash(sourceState.Path, text5, sourceState.Metadata.Length, cancellationToken); } catch (BackupSourceChangedException) { return FailureWithCleanup(MigrationBackupOutcome.SourceChangedDuringCopy, correlation, "source-grew-during-copy", list, root, text); } BackupFileMetadata fileMetadata2 = _storage.GetFileMetadata(sourceState.Path); BackupFileMetadata fileMetadata3 = _storage.GetFileMetadata(text5); if (!sourceState.Metadata.StableEquals(fileMetadata2) || fileMetadata3.Length != sourceState.Metadata.Length) { return FailureWithCleanup(MigrationBackupOutcome.SourceChangedDuringCopy, correlation, "source-changed-during-copy", list, root, text); } list.Add(new MigrationBackupFile(sourceState.LogicalName, sourceState.Path, text4, sourceState.Metadata.Length, sha)); } cancellationToken.ThrowIfCancellationRequested(); WriteManifest(text, request.MigrationId, correlation, list); WriteRestoreInstructions(text); _storage.WriteAllTextNew(_storage.Combine(text, ".runicsafety-backup"), "RUNIC_SAFETY_BACKUP_V1" + Environment.NewLine); _storage.MoveDirectory(text, text3); text = string.Empty; if (!ValidateBackupInternal(text3, request.MigrationId, correlation, list, out var failureCode2)) { _diagnostics.Record(correlation, "migration-backup", "post-commit-validation-failed", SafetyDiagnosticSeverity.Error); return new MigrationBackupResult(MigrationBackupOutcome.IoFailure, text3, correlation, "post-commit-validation-failed-" + BoundCode(failureCode2), list.AsReadOnly()); } try { ApplyRetention(root, text3, request.RetentionCount, list2); } catch (Exception) { _diagnostics.Record(correlation, "migration-backup", "retention-failed", SafetyDiagnosticSeverity.Warning); } MigrationBackupResult result = new MigrationBackupResult(MigrationBackupOutcome.Succeeded, text3, correlation, string.Empty, list.AsReadOnly()); _diagnostics.Record(correlation, "migration-backup", "committed"); return result; } catch (OperationCanceledException) { CleanupPartial(root, text); return Failure(MigrationBackupOutcome.Cancelled, correlation, "cancelled", list); } catch (Exception) { CleanupPartial(root, text); return Failure(MigrationBackupOutcome.IoFailure, correlation, "io-failure", list); } } public MigrationExecutionResult ExecuteAfterBackup(MigrationBackupRequest request, Action mutation, CancellationToken cancellationToken) { string correlation = _diagnostics.NewCorrelationId("backup"); if (mutation == null) { return new MigrationExecutionResult(Failure(MigrationBackupOutcome.InvalidRequest, correlation, "mutation-required", new List<MigrationBackupFile>()), mutationInvoked: false, null); } if (!TryAcquireRootLease(request, correlation, out var canonicalRoot, out var lease, out var failure)) { return new MigrationExecutionResult(failure, mutationInvoked: false, null); } using (lease) { MigrationBackupResult migrationBackupResult = CreateBackupUnderLease(request, cancellationToken, correlation, canonicalRoot); if (!migrationBackupResult.Succeeded) { return new MigrationExecutionResult(migrationBackupResult, mutationInvoked: false, null); } if (cancellationToken.IsCancellationRequested) { return new MigrationExecutionResult(CommittedFailure(migrationBackupResult, MigrationBackupOutcome.Cancelled, "cancelled-before-mutation"), mutationInvoked: false, null); } List<Stream> list = new List<Stream>(migrationBackupResult.Files.Count + 3); try { string fullPath = _storage.GetFullPath(migrationBackupResult.BackupDirectory); if (!IsDirectChild(canonicalRoot, fullPath)) { return new MigrationExecutionResult(CommittedFailure(migrationBackupResult, MigrationBackupOutcome.IoFailure, "backup-root-proof-failed"), mutationInvoked: false, null); } ProtectFile(list, _storage.Combine(fullPath, ".runicsafety-backup")); ProtectFile(list, _storage.Combine(fullPath, "manifest.runic")); ProtectFile(list, _storage.Combine(fullPath, "RESTORE.txt")); for (int i = 0; i < migrationBackupResult.Files.Count; i++) { MigrationBackupFile migrationBackupFile = migrationBackupResult.Files[i]; if (migrationBackupFile == null || !IsSimpleFileName(migrationBackupFile.BackupFileName)) { throw new InvalidDataException("Backup result contains an unsafe filename."); } ProtectFile(list, _storage.Combine(fullPath, migrationBackupFile.BackupFileName)); } string failureCode = "marker-unavailable"; if (!ValidateBackupInternal(fullPath, request.MigrationId, migrationBackupResult.CorrelationId, migrationBackupResult.Files, out failureCode)) { return new MigrationExecutionResult(CommittedFailure(migrationBackupResult, MigrationBackupOutcome.IoFailure, "pre-mutation-validation-failed-" + BoundCode(failureCode)), mutationInvoked: false, null); } if (cancellationToken.IsCancellationRequested) { return new MigrationExecutionResult(CommittedFailure(migrationBackupResult, MigrationBackupOutcome.Cancelled, "cancelled-before-mutation"), mutationInvoked: false, null); } try { mutation(); _diagnostics.Record(migrationBackupResult.CorrelationId, "migration", "mutation-completed"); return new MigrationExecutionResult(migrationBackupResult, mutationInvoked: true, null); } catch (Exception mutationFailure) { _diagnostics.Record(migrationBackupResult.CorrelationId, "migration", "mutation-threw", SafetyDiagnosticSeverity.Error); return new MigrationExecutionResult(migrationBackupResult, mutationInvoked: true, mutationFailure); } } catch (Exception) { return new MigrationExecutionResult(CommittedFailure(migrationBackupResult, MigrationBackupOutcome.IoFailure, "pre-mutation-validation-io-failure"), mutationInvoked: false, null); } finally { for (int num = list.Count - 1; num >= 0; num--) { list[num]?.Dispose(); } } } } public bool ValidateBackup(string backupDirectory, out string failureCode) { return ValidateBackupInternal(backupDirectory, null, null, null, out failureCode); } private bool ValidateBackupInternal(string backupDirectory, string expectedMigrationId, string expectedCorrelationId, IReadOnlyList<MigrationBackupFile> expectedFiles, out string failureCode) { failureCode = string.Empty; try { if (string.IsNullOrWhiteSpace(backupDirectory)) { failureCode = "backup-directory-required"; return false; } string fullPath = _storage.GetFullPath(backupDirectory); if (!_storage.DirectoryExists(fullPath) || _storage.IsDirectoryReparsePoint(fullPath) || !_storage.FileExists(_storage.Combine(fullPath, ".runicsafety-backup")) || !_storage.FileExists(_storage.Combine(fullPath, "manifest.runic")) || !_storage.FileExists(_storage.Combine(fullPath, "RESTORE.txt"))) { failureCode = "backup-incomplete"; return false; } BackupFileMetadata fileMetadata = _storage.GetFileMetadata(_storage.Combine(fullPath, ".runicsafety-backup")); BackupFileMetadata fileMetadata2 = _storage.GetFileMetadata(_storage.Combine(fullPath, "manifest.runic")); BackupFileMetadata fileMetadata3 = _storage.GetFileMetadata(_storage.Combine(fullPath, "RESTORE.txt")); if (fileMetadata.IsReparsePoint || fileMetadata2.IsReparsePoint || fileMetadata3.IsReparsePoint || fileMetadata.Length > 128 || fileMetadata2.Length > 16777216 || fileMetadata3.Length > 1048576) { failureCode = "backup-metadata-file-limit"; return false; } if (!string.Equals(ReadBoundedUtf8(_storage.Combine(fullPath, ".runicsafety-backup"), 128L).Trim(), "RUNIC_SAFETY_BACKUP_V1", StringComparison.Ordinal)) { failureCode = "marker-invalid"; return false; } if (!ReadBoundedUtf8(_storage.Combine(fullPath, "RESTORE.txt"), 1048576L).StartsWith("Runic Safety migration backup", StringComparison.Ordinal)) { failureCode = "restore-instructions-invalid"; return false; } string[] array = ReadBoundedUtf8Lines(_storage.Combine(fullPath, "manifest.runic"), 16777216L, 1040); if (array.Length < 4 || array.Length > 1040 || !string.Equals(array[0], "RUNIC_SAFETY_BACKUP_V1", StringComparison.Ordinal) || !array[1].StartsWith("MIGRATION\t", StringComparison.Ordinal) || !array[2].StartsWith("CORRELATION\t", StringComparison.Ordinal) || !array[3].StartsWith("CREATED_UTC\t", StringComparison.Ordinal)) { failureCode = "manifest-invalid"; return false; } string[] array2 = array[1].Split('\t'); string[] array3 = array[2].Split('\t'); string[] array4 = array[3].Split('\t'); if (array2.Length != 2 || array3.Length != 2 || array4.Length != 2 || array2[1].Length > 256 || array3[1].Length > 256 || array4[1].Length > 64) { failureCode = "manifest-metadata-invalid"; return false; } string text = Decode(array2[1]); string text2 = Decode(array3[1]); if (string.IsNullOrWhiteSpace(text) || text.Length > 96 || string.IsNullOrWhiteSpace(text2) || text2.Length > 128 || (expectedMigrationId != null && !string.Equals(text, expectedMigrationId, StringComparison.Ordinal)) || (expectedCorrelationId != null && !string.Equals(text2, expectedCorrelationId, StringComparison.Ordinal)) || !DateTime.TryParseExact(array4[1], "O", CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var _)) { failureCode = "manifest-metadata-invalid"; return false; } int num = 0; long num2 = 0L; HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); Dictionary<string, MigrationBackupFile> dictionary = null; if (expectedFiles != null) { if (expectedFiles.Count < 1 || expectedFiles.Count > 1024) { failureCode = "expected-file-set-invalid"; return false; } dictionary = new Dictionary<string, MigrationBackupFile>(expectedFiles.Count, StringComparer.OrdinalIgnoreCase); for (int i = 0; i < expectedFiles.Count; i++) { MigrationBackupFile migrationBackupFile = expectedFiles[i]; if (migrationBackupFile == null || !IsSimpleFileName(migrationBackupFile.BackupFileName) || migrationBackupFile.Length < 0 || !IsSha256(migrationBackupFile.Sha256) || !dictionary.TryAdd(migrationBackupFile.BackupFileName, migrationBackupFile)) { failureCode = "expected-file-set-invalid"; return false; } } } for (int j = 4; j < array.Length; j++) { string text3 = array[j]; if (!text3.StartsWith("FILE\t", StringComparison.Ordinal)) { failureCode = "manifest-entry-invalid"; return false; } if (++num > 1024) { failureCode = "manifest-file-limit"; return false; } string[] array5 = text3.Split('\t'); if (array5.Length != 6 || !long.TryParse(array5[4], NumberStyles.None, CultureInfo.InvariantCulture, out var result2) || result2 < 0 || array5[1].Length > 256 || array5[2].Length > 65536 || array5[3].Length > 256 || array5[5].Length != 64) { failureCode = "manifest-entry-invalid"; return false; } string text4 = Decode(array5[1]); string text5 = Decode(array5[2]); string text6 = Decode(array5[3]); if (string.IsNullOrWhiteSpace(text4) || text4.Length > 96 || string.IsNullOrWhiteSpace(text5) || text5.Length > 4096 || !Path.IsPathRooted(text5) || !IsSimpleFileName(text6) || !hashSet.Add(text6) || !IsSha256(array5[5])) { failureCode = "manifest-path-invalid"; return false; } if (dictionary != null && (!dictionary.TryGetValue(text6, out var value) || !string.Equals(value.LogicalName, text4, StringComparison.Ordinal) || !string.Equals(value.OriginalPath, text5, StringComparison.Ordinal) || value.Length != result2 || !string.Equals(value.Sha256, array5[5], StringComparison.OrdinalIgnoreCase))) { failureCode = "backup-file-set-changed"; return false; } string path = _storage.Combine(fullPath, text6); if (!_storage.FileExists(path)) { failureCode = "backup-file-missing-or-sized-wrong"; return false; } BackupFileMetadata fileMetadata4 = _storage.GetFileMetadata(path); if (fileMetadata4.IsReparsePoint || fileMetadata4.Length != result2) { failureCode = "backup-file-missing-or-sized-wrong"; return false; } try { num2 = checked(num2 + result2); } catch (OverflowException) { failureCode = "manifest-size-overflow"; return false; } if (num2 > 68719476736L) { failureCode = "manifest-size-limit"; return false; } if (!string.Equals(HashFile(path, result2, CancellationToken.None), array5[5], StringComparison.OrdinalIgnoreCase)) { failureCode = "backup-hash-mismatch"; return false; } } if (num == 0 || (dictionary != null && num != dictionary.Count)) { failureCode = ((num == 0) ? "manifest-has-no-files" : "backup-file-set-changed"); return false; } return true; } catch (Exception) { failureCode = "backup-validation-io-failure"; return false; } } private bool TryValidateRequest(MigrationBackupRequest request, out string root, out MigrationBackupOutcome outcome, out string failureCode) { root = string.Empty; outcome = MigrationBackupOutcome.InvalidRequest; failureCode = "invalid-request"; if (request == null || string.IsNullOrWhiteSpace(request.MigrationId) || request.MigrationId.Length > 96 || request.Sources == null || request.Sources.Count == 0 || string.IsNullOrWhiteSpace(request.DestinationRoot) || request.RetentionCount < 1 || request.RetentionCount > 128 || request.MaximumFiles < 1 || request.MaximumFiles > 1024 || request.MaximumTotalBytes < 1 || request.MaximumTotalBytes > 68719476736L) { return false; } if (request.Sources.Count > request.MaximumFiles) { outcome = MigrationBackupOutcome.FileLimitExceeded; failureCode = "configured-file-limit"; return false; } root = _storage.GetFullPath(request.DestinationRoot); if (string.IsNullOrWhiteSpace(root)) { return false; } return true; } private bool TryAcquireRootLease(MigrationBackupRequest request, string correlation, out string canonicalRoot, out IDisposable lease, out MigrationBackupResult failure) { canonicalRoot = string.Empty; lease = null; failure = null; List<MigrationBackupFile> files = new List<MigrationBackupFile>(); try { if (!TryValidateRequest(request, out canonicalRoot, out var outcome, out var failureCode)) { failure = Failure(outcome, correlation, failureCode, files); return false; } } catch (Exception) { failure = Failure(MigrationBackupOutcome.IoFailure, correlation, "backup-root-canonicalization-failed", files); return false; } lock (RootGateSync) { if (ActiveBackupRoots.Contains(canonicalRoot)) { failure = Failure(MigrationBackupOutcome.IoFailure, correlation, "backup-root-busy", files); return false; } if (ActiveBackupRoots.Count >= 128) { failure = Failure(MigrationBackupOutcome.IoFailure, correlation, "backup-root-gate-capacity", files); return false; } ActiveBackupRoots.Add(canonicalRoot); } lease = new RootLease(canonicalRoot); return true; } private static bool SameRoot(string left, string right) { if (!string.IsNullOrEmpty(left) && !string.IsNullOrEmpty(right)) { return RootPathComparer.Equals(Path.GetFullPath(left), Path.GetFullPath(right)); } return false; } private static void ReleaseRoot(string root) { if (string.IsNullOrEmpty(root)) { return; } lock (RootGateSync) { ActiveBackupRoots.Remove(root); } } private string CopyAndHash(string source, string destination, long expectedLength, CancellationToken cancellationToken) { using Stream stream = _storage.OpenRead(source); using Stream stream2 = _storage.CreateNew(destination); using SHA256 sHA = SHA256.Create(); byte[] array = new byte[81920]; long num = 0L; int num2; while ((num2 = stream.Read(array, 0, array.Length)) != 0) { cancellationToken.ThrowIfCancellationRequested(); num = checked(num + num2); if (num > expectedLength) { throw new BackupSourceChangedException(); } stream2.Write(array, 0, num2); sHA.TransformBlock(array, 0, num2, null, 0); } sHA.TransformFinalBlock(Array.Empty<byte>(), 0, 0); if (num != expectedLength) { throw new BackupSourceChangedException(); } stream2.Flush(); if (stream2 is FileStream fileStream) { fileStream.Flush(flushToDisk: true); } return ToHex(sHA.Hash); } private string HashFile(string path, long expectedLength, CancellationToken cancellationToken) { using Stream stream = _storage.OpenRead(path); using SHA256 sHA = SHA256.Create(); byte[] array = new byte[81920]; long num = 0L; int num2; while ((num2 = stream.Read(array, 0, array.Length)) != 0) { cancellationToken.ThrowIfCancellationRequested(); num = checked(num + num2); if (num > expectedLength) { throw new InvalidDataException("Backup file grew during validation."); } sHA.TransformBlock(array, 0, num2, null, 0); } sHA.TransformFinalBlock(Array.Empty<byte>(), 0, 0); if (num != expectedLength) { throw new InvalidDataException("Backup file shrank during validation."); } return ToHex(sHA.Hash); } private void ProtectFile(ICollection<Stream> protection, string path) { Stream stream = _storage.OpenRead(path); if (stream == null || !stream.CanRead) { stream?.Dispose(); throw new IOException("A committed backup file could not be protected."); } protection.Add(stream); } private string ReadBoundedUtf8(string path, long maximumBytes) { if (maximumBytes < 0 || maximumBytes > int.MaxValue) { throw new ArgumentOutOfRangeException("maximumBytes"); } using Stream stream = _storage.OpenRead(path); using MemoryStream memoryStream = new MemoryStream((int)Math.Min(maximumBytes, 81920L)); checked { byte[] array = new byte[(maximumBytes >= 81920) ? 81920 : ((int)maximumBytes + 1)]; long num = 0L; int num2; while ((num2 = stream.Read(array, 0, array.Length)) != 0) { num += num2; if (num > maximumBytes) { throw new InvalidDataException("Bounded UTF-8 file exceeded its limit."); } memoryStream.Write(array, 0, num2); } return StrictUtf8.GetString(memoryStream.ToArray()); } } private string[] ReadBoundedUtf8Lines(string path, long maximumBytes, int maximumLines) { string s = ReadBoundedUtf8(path, maximumBytes); List<string> list = new List<string>(Math.Min(maximumLines, 64)); using (StringReader stringReader = new StringReader(s)) { string item; while ((item = stringReader.ReadLine()) != null) { if (list.Count >= maximumLines) { throw new InvalidDataException("Bounded manifest exceeded its line limit."); } list.Add(item); } } return list.ToArray(); } private void WriteManifest(string partial, string migrationId, string correlation, IReadOnlyList<MigrationBackupFile> files) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("RUNIC_SAFETY_BACKUP_V1"); stringBuilder.Append("MIGRATION\t").AppendLine(Encode(migrationId)); stringBuilder.Append("CORRELATION\t").AppendLine(Encode(correlation)); stringBuilder.Append("CREATED_UTC\t").AppendLine(_clock().ToUniversalTime().ToString("O", CultureInfo.InvariantCulture)); foreach (MigrationBackupFile file in files) { stringBuilder.Append("FILE\t").Append(Encode(file.LogicalName)).Append('\t') .Append(Encode(file.OriginalPath)) .Append('\t') .Append(Encode(file.BackupFileName)) .Append('\t') .Append(file.Length.ToString(CultureInfo.InvariantCulture)) .Append('\t') .Append(file.Sha256) .AppendLine(); } _storage.WriteAllTextNew(_storage.Combine(partial, "manifest.runic"), stringBuilder.ToString()); } private void WriteRestoreInstructions(string partial) { _storage.WriteAllTextNew(_storage.Combine(partial, "RESTORE.txt"), "Runic Safety migration backup\n\n1. Stop Valheim and the dedicated server.\n2. Validate this backup with Runic Safety before restoring.\n3. Read manifest.runic; each FILE row contains Base64 UTF-8 logical name, original path, backup filename, byte length, and SHA-256.\n4. Copy each backup file to its decoded original path only after preserving the current file.\n5. Keep world and character file families from the same timestamp together.\nRunic Safety intentionally does not auto-restore multi-file saves because that is not atomic.\n"); } private void ApplyRetention(string root, string current, int retentionCount, IReadOnlyList<SourceState> sources) { List<BackupDirectory> list = new List<BackupDirectory>(); string[] directories = _storage.GetDirectories(root, "backup-*"); foreach (string path in directories) { string fullPath = _storage.GetFullPath(path); string path2 = _storage.Combine(fullPath, ".runicsafety-backup"); if (IsDirectChild(root, fullPath) && !_storage.IsDirectoryReparsePoint(fullPath) && _storage.FileExists(path2) && _storage.GetFileMetadata(path2).Length <= 128 && string.Equals(ReadBoundedUtf8(path2, 128L).Trim(), "RUNIC_SAFETY_BACKUP_V1", StringComparison.Ordinal)) { list.Add(new BackupDirectory(fullPath, _storage.GetDirectoryCreationUtc(fullPath))); } } list.Sort(delegate(BackupDirectory left, BackupDirectory right) { int num2 = right.CreatedUtc.CompareTo(left.CreatedUtc); return (num2 == 0) ? RootPathComparer.Compare(right.Path, left.Path) : num2; }); for (int num = retentionCount; num < list.Count; num++) { if (!string.Equals(list[num].Path, current, RootPathComparison) && !ContainsSource(list[num].Path, sources)) { _storage.DeleteDirectory(list[num].Path, recursive: true); } } } private static bool ContainsSource(string directory, IReadOnlyList<SourceState> sources) { string value = EnsureTrailingSeparator(Path.GetFullPath(directory)); for (int i = 0; i < sources.Count; i++) { if (Path.GetFullPath(sources[i].Path).StartsWith(value, RootPathComparison)) { return true; } } return false; } private MigrationBackupResult FailureWithCleanup(MigrationBackupOutcome outcome, string correlation, string code, List<MigrationBackupFile> files, string root, string partial) { CleanupPartial(root, partial); return Failure(outcome, correlation, code, files); } private MigrationBackupResult Failure(MigrationBackupOutcome outcome, string correlation, string code, List<MigrationBackupFile> files) { _diagnostics.Record(correlation, "migration-backup", code, SafetyDiagnosticSeverity.Warning); return new MigrationBackupResult(outcome, string.Empty, correlation, code, files.AsReadOnly()); } private MigrationBackupResult CommittedFailure(MigrationBackupResult backup, MigrationBackupOutcome outcome, string code) { _diagnostics.Record(backup.CorrelationId, "migration-backup", code, SafetyDiagnosticSeverity.Error); return new MigrationBackupResult(outcome, backup.BackupDirectory, backup.CorrelationId, code, backup.Files); } private void CleanupPartial(string root, string partial) { if (string.IsNullOrWhiteSpace(root) || string.IsNullOrWhiteSpace(partial)) { return; } try { string fullPath = _storage.GetFullPath(root); string fullPath2 = _storage.GetFullPath(partial); string fileName = _storage.GetFileName(fullPath2); if (IsDirectChild(fullPath, fullPath2) && fileName.StartsWith(".partial-", StringComparison.Ordinal) && !_storage.IsDirectoryReparsePoint(fullPath2) && _storage.DirectoryExists(fullPath2)) { _storage.DeleteDirectory(fullPath2, recursive: true); } } catch (Exception) { } } private static bool IsDirectChild(string parent, string child) { string text = EnsureTrailingSeparator(Path.GetFullPath(parent)); string fullPath = Path.GetFullPath(child); if (!fullPath.StartsWith(text, RootPathComparison)) { return false; } string text2 = fullPath.Substring(text.Length); if (text2.Length != 0 && text2.IndexOf(Path.DirectorySeparatorChar) < 0) { return text2.IndexOf(Path.AltDirectorySeparatorChar) < 0; } return false; } private static string EnsureTrailingSeparator(string value) { if (!value.EndsWith(Path.DirectorySeparatorChar.ToString(), StringComparison.Ordinal) && !value.EndsWith(Path.AltDirectorySeparatorChar.ToString(), StringComparison.Ordinal)) { return value + Path.DirectorySeparatorChar; } return value; } private static string CreateBackupName(int index, string path) { string text = Path.GetExtension(path); if (text.Length > 16 || text.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) { text = ".bin"; } return index.ToString("D4", CultureInfo.InvariantCulture) + text.ToLowerInvariant(); } private static string BoundLogicalName(string value, int index) { string result = "source-" + index.ToString(CultureInfo.InvariantCulture); if (string.IsNullOrWhiteSpace(value)) { return result; } string text = value.Trim(); if (text.Length > 96) { return text.Substring(0, 96); } return text; } private static string BoundCode(string value) { if (string.IsNullOrWhiteSpace(value)) { return "unknown"; } string text = value.Trim(); StringBuilder stringBuilder = new StringBuilder(Math.Min(text.Length, 48)); for (int i = 0; i < text.Length; i++) { if (stringBuilder.Length >= 48) { break; } char c = text[i]; stringBuilder.Append((char.IsLetterOrDigit(c) || c == '-' || c == '_') ? c : '_'); } return stringBuilder.ToString(); } private static string ShortCorrelation(string correlation) { string text = correlation ?? string.Empty; int num = text.LastIndexOf('-'); if (num >= 0 && num + 1 < text.Length) { text = text.Substring(num + 1); } if (text.Length > 16) { return text.Substring(text.Length - 16); } return text; } private static string Encode(string value) { return Convert.ToBase64String(Encoding.UTF8.GetBytes(value ?? string.Empty)); } private static string Decode(string value) { return StrictUtf8.GetString(Convert.FromBase64String(value)); } private static string ToHex(byte[] bytes) { StringBuilder stringBuilder = new StringBuilder(bytes.Length * 2); foreach (byte b in bytes) { stringBuilder.Append(b.ToString("x2", CultureInfo.InvariantCulture)); } return stringBuilder.ToString(); } private static bool IsSimpleFileName(string value) { if (!string.IsNullOrWhiteSpace(value) && string.Equals(Path.GetFileName(value), value, StringComparison.Ordinal)) { return value.IndexOfAny(Path.GetInvalidFileNameChars()) < 0; } return false; } private static bool IsSha256(string value) { if (value == null || value.Length != 64) { return false; } foreach (char c in value) { if ((c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F')) { return false; } } return true; } } internal readonly struct BackupFileMetadata { internal long Length { get; } internal DateTime LastWriteUtc { get; } internal bool IsReparsePoint { get; } internal BackupFileMetadata(long length, DateTime lastWriteUtc, bool isReparsePoint) { Length = length; LastWriteUtc = lastWriteUtc; IsReparsePoint = isReparsePoint; } internal bool StableEquals(BackupFileMetadata other) { if (Length == other.Length && LastWriteUtc == other.LastWriteUtc) { return IsReparsePoint == other.IsReparsePoint; } return false; } } internal readonly struct MigrationBackupDefaults { internal string DestinationRoot { get; } internal int RetentionCount { get; } internal int MaximumFiles { get; } internal long MaximumTotalBytes { get; } internal MigrationBackupDefaults(string destinationRoot, int retentionCount, int maximumFiles, long maximumTotalBytes) { DestinationRoot = destinationRoot; RetentionCount = retentionCount; MaximumFiles = maximumFiles; MaximumTotalBytes = maximumTotalBytes; } } internal interface IBackupStorage { string GetFullPath(string path); string Combine(string left, string right); string GetFileName(string path); bool FileExists(string path); bool DirectoryExists(string path); BackupFileMetadata GetFileMetadata(string path); Stream OpenRead(string path); Stream CreateNew(string path); void CreateDirectory(string path); void MoveDirectory(string source, string destination); void DeleteDirectory(string path, bool recursive); string[] GetDirectories(string path, string pattern); DateTime GetDirectoryCreationUtc(string path); bool IsDirectoryReparsePoint(string path); void WriteAllTextNew(string path, string contents); string ReadAllText(string path); string[] ReadAllLines(string path); } internal sealed class PhysicalBackupStorage : IBackupStorage { public string GetFullPath(string path) { return Path.GetFullPath(path); } public string Combine(string left, string right) { return Path.Combine(left, right); } public string GetFileName(string path) { return Path.GetFileName(path); } public bool FileExists(string path) { return File.Exists(path); } public bool DirectoryExists(string path) { return Directory.Exists(path); } public BackupFileMetadata GetFileMetadata(string path) { FileInfo fileInfo = new FileInfo(path); return new BackupFileMetadata(fileInfo.Length, fileInfo.LastWriteTimeUtc, (fileInfo.Attributes & FileAttributes.ReparsePoint) != 0); } public Stream OpenRead(string path) { return new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, FileOptions.SequentialScan); } public Stream CreateNew(string path) { return new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 81920, FileOptions.WriteThrough); } public void CreateDirectory(string path) { Directory.CreateDirectory(path); } public void MoveDirectory(string source, string destination) { Directory.Move(source, destination); } public void DeleteDirectory(string path, bool recursive) { Directory.Delete(path, recursive); } public string[] GetDirectories(string path, string pattern) { return Directory.GetDirectories(path, pattern); } public DateTime GetDirectoryCreationUtc(string path) { return Directory.GetCreationTimeUtc(path); } public bool IsDirectoryReparsePoint(string path) { return (new DirectoryInfo(path).Attributes & FileAttributes.ReparsePoint) != 0; } public void WriteAllTextNew(string path, string contents) { byte[] bytes = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false).GetBytes(contents ?? string.Empty); using FileStream fileStream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.WriteThrough); fileStream.Write(bytes, 0, bytes.Length); fileStream.Flush(flushToDisk: true); } public string ReadAllText(string path) { return File.ReadAllText(path, Encoding.UTF8); } public string[] ReadAllLines(string path) { return File.ReadAllLines(path, Encoding.UTF8); } } public sealed class ProtectedItemPolicy : IProtectedItemPolicy { private readonly struct ProviderEntry { internal IItemProtectionProvider Provider { get; } internal int Priority { get; } internal long Token { get; } internal ProviderEntry(IItemProtectionProvider provider, int priority, long token) { Provider = provider; Priority = priority; Token = token; } } private sealed class Registration : IDisposable { private ProtectedItemPolicy _owner; private readonly long _token; internal Registration(ProtectedItemPolicy owner, long token) { _owner = owner; _token = token; } public void Dispose() { ProtectedItemPolicy owner = _owner; _owner = null; owner?.Unregister(_token); } } private const int MaximumProviders = 16; private readonly object _sync = new object(); private readonly List<ProviderEntry> _providers = new List<ProviderEntry>(); private readonly ISafetyDiagnosticService _diagnostics; private readonly Func<bool> _rareConfirmationEnabled; private readonly Func<bool> _administratorBypassEnabled; private long _token; public bool HasExternalProvider { get { lock (_sync) { return _providers.Count != 0; } } } public int ProviderCount { get { lock (_sync) { return _providers.Count; } } } public ProtectedItemPolicy(ISafetyDiagnosticService diagnostics, Func<bool> rareConfirmationEnabled, Func<bool> administratorBypassEnabled) { _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); _rareConfirmationEnabled = rareConfirmationEnabled ?? throw new ArgumentNullException("rareConfirmationEnabled"); _administratorBypassEnabled = administratorBypassEnabled ?? throw new ArgumentNullException("administratorBypassEnabled"); } public IDisposable RegisterProvider(IItemProtectionProvider provider, int priority = 0) { if (provider == null) { throw new ArgumentNullException("provider"); } if (!IsIdentifier(provider.ProviderId)) { throw new ArgumentException("ProviderId must be a lowercase dotted identifier.", "provider"); } lock (_sync) { if (_providers.Count >= 16) { throw new InvalidOperationException("The protected-item provider limit is 16."); } foreach (ProviderEntry provider2 in _providers) { if (string.Equals(provider2.Provider.ProviderId, provider.ProviderId, StringComparison.Ordinal)) { throw new InvalidOperationException("Provider already registered: " + provider.ProviderId); } } long token = ++_token; _providers.Add(new ProviderEntry(provider, priority, token)); _providers.Sort(CompareProviders); return new Registration(this, token); } } public ItemProtectionDecision Evaluate(ItemProtectionRequest request) { string text = request?.CorrelationId; if (string.IsNullOrWhiteSpace(text)) { text = _diagnostics.NewCorrelationId("protect"); } if (request?.Item == null || string.IsNullOrWhiteSpace(request.Item.StableItemId)) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.InvalidRequest)); } if (request.Administrator && _administratorBypassEnabled()) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Allow, ProtectionReason.AdministratorBypass)); } if (request.Item.Equipped) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.Equipped)); } if (request.Item.QuestItem) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.QuestItem)); } if (request.Item.LockState == ItemLockState.Locked) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.Locked)); } if (request.ExternalInventoryCapabilityAdvertised && request.Item.LockState == ItemLockState.Unknown) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.ProviderUnavailable)); } ProviderEntry[] array; lock (_sync) { array = _providers.ToArray(); } ItemProtectionDecision itemProtectionDecision = null; ProviderEntry[] array2 = array; for (int i = 0; i < array2.Length; i++) { ProviderEntry providerEntry = array2[i]; ItemProtectionDecision itemProtectionDecision2; try { itemProtectionDecision2 = providerEntry.Provider.Evaluate(request); } catch (Exception) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.ProviderFailure, providerEntry.Provider.ProviderId)); } if (itemProtectionDecision2 == null) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.Deny, ProtectionReason.ProviderFailure, providerEntry.Provider.ProviderId)); } if (itemProtectionDecision2.Outcome == ProtectionOutcome.Deny) { return Record(text, itemProtectionDecision2); } if (itemProtectionDecision2.Outcome == ProtectionOutcome.RequireConfirmation && itemProtectionDecision == null) { itemProtectionDecision = itemProtectionDecision2; } } if (itemProtectionDecision != null) { return Record(text, itemProtectionDecision); } if (request.Item.ConfiguredRare && _rareConfirmationEnabled()) { return Record(text, new ItemProtectionDecision(ProtectionOutcome.RequireConfirmation, ProtectionReason.ConfiguredRareItem)); } return Record(text, new ItemProtectionDecision(ProtectionOutcome.Allow, ProtectionReason.None)); } private ItemProtectionDecision Record(string correlation, ItemProtectionDecision decision) { SafetyDiagnosticSeverity severity = ((decision.Outcome == ProtectionOutcome.Deny) ? SafetyDiagnosticSeverity.Warning : SafetyDiagnosticSeverity.Information); _diagnostics.Record(correlation, "protected-item", decision.Outcome.ToString().ToLowerInvariant() + "-" + decision.Reason.ToString().ToLowerInvariant(), severity); return decision; } private void Unregister(long token) { lock (_sync) { _providers.RemoveAll((ProviderEntry entry) => entry.Token == token); } } private static int CompareProviders(ProviderEntry left, ProviderEntry right) { int num = right.Priority.CompareTo(left.Priority); if (num != 0) { return num; } int num2 = StringComparer.Ordinal.Compare(left.Provider.ProviderId, right.Provider.ProviderId); if (num2 == 0) { return left.Token.CompareTo(right.Token); } return num2; } private static bool IsIdentifier(string value) { if (string.IsNullOrWhiteSpace(value) || value.Length > 96) { return false; } bool flag = true; foreach (char c in value) { switch (c) { case '.': if (flag) { return false; } flag = true; continue; default: if ((c < '0' || c > '9') && c != '-') { return false; } break; case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': case 'g': case 'h': case 'i': case 'j': case 'k': case 'l': case 'm': case 'n': case 'o': case 'p': case 'q': case 'r': case 's': case 't': case 'u': case 'v': case 'w': case 'x': case 'y': case 'z': break; } flag = false; } return !flag; } } public sealed class RecoveryPlanningService : IRecoveryPlanningService { private readonly struct ProviderEntry { internal IInventoryTopologyProvider Provider { get; } internal long Token { get; } internal ProviderEntry(IInventoryTopologyProvider provider, long token) { Provider = provider; Token = token; } } private sealed class Registration : IDisposable { private RecoveryPlanningService _owner; private readonly long _token; internal Registration(RecoveryPlanningService owner, long token) { _owner = owner; _token = token; } public void Dispose() { RecoveryPlanningService owner = _owner; _owner = null; owner?.Unregister(_token); } } private const int MaximumProviders = 8; private readonly object _sync = new object(); private readonly List<ProviderEntry> _providers = new List<ProviderEntry>(); private readonly ISafetyDiagnosticService _diagnostics; private long _token; public bool HasTopologyProvider { get { lock (_sync) { return _providers.Count != 0; } } } public RecoveryPlanningService(ISafetyDiagnosticService diagnostics) { _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); } public IDisposable RegisterTopologyProvider(IInventoryTopologyProvider provider) { if (provider == null) { throw new ArgumentNullException("provider"); } if (string.IsNullOrWhiteSpace(provider.ProviderId) || provider.ProviderId.Length > 96) { throw new ArgumentException("A bounded topology provider ID is required.", "provider"); } lock (_sync) { if (_providers.Count >= 8) { throw new InvalidOperationException("The topology provider limit is 8."); } foreach (ProviderEntry provider2 in _providers) { if (string.Equals(provider2.Provider.ProviderId, provider.ProviderId, StringComparison.Ordinal)) { throw new InvalidOperationException("Topology provider already registered: " + provider.ProviderId); } } long token = ++_token; _providers.Add(new ProviderEntry(provider, token)); _providers.Sort((ProviderEntry left, ProviderEntry right) => StringComparer.Ordinal.Compare(left.Provider.ProviderId, right.Provider.ProviderId)); return new Registration(this, token); } } public bool TryCaptureTopology(long playerId, out InventoryTopologySnapshot snapshot, out string failureCode) { ProviderEntry[] array; lock (_sync) { array = _providers.ToArray(); } snapshot = null; failureCode = ((array.Length == 0) ? "no-topology-provider" : string.Empty); ProviderEntry[] array2 = array; for (int i = 0; i < array2.Length; i++) { ProviderEntry providerEntry = array2[i]; try { if (!providerEntry.Provider.TryCapture(playerId, out var snapshot2, out var failureCode2)) { failureCode = Bound(failureCode2, "provider-declined"); continue; } if (snapshot2 == null) { failureCode = "provider-returned-null"; continue; } snapshot = snapshot2; failureCode = string.Empty; return true; } catch (Exception) { failureCode = "provider-threw"; } } return false; } public RecoveryPlan Plan(RecoveryPlanningRequest request) { string correlation = _diagnostics.NewCorrelationId("recovery"); if (request == null || request.VanillaWidth <= 0 || request.VanillaHeight <= 0 || request.VanillaOccupiedSlots < 0) { return Result(RecoveryPlanOutcome.Invalid, 0, 0, correlation, "invalid-request"); } int num; try { num = checked(request.VanillaWidth * request.VanillaHeight); } catch (OverflowException) { return Result(RecoveryPlanOutcome.Invalid, 0, 0, correlation, "capacity-overflow"); } if (request.VanillaOccupiedSlots > num) { return Result(RecoveryPlanOutcome.BlockedUnsupportedExternalTopology, request.VanillaOccupiedSlots, num, correlation, "vanilla-capacity-insufficient"); } if (!request.VanillaSerializationVerified) { return Result(RecoveryPlanOutcome.BlockedSerializationFailure, request.VanillaOccupiedSlots, num, correlation, "verify-vanilla-serialization"); } InventoryTopologySnapshot topology = request.Topology; if (topology == null) { return Result(RecoveryPlanOutcome.SafeVanillaTombstone, request.VanillaOccupiedSlots, num, correlation, "none"); } if (!ValidTopology(topology) || !CompatibleProtocol(topology.ProtocolVersion)) { return Result(RecoveryPlanOutcome.BlockedIncompatibleTopology, Math.Max(request.VanillaOccupiedSlots, topology.OccupiedSlots), Math.Max(num, topology.RecoveryCapacitySlots), correlation, "update-inventory-topology-provider"); } if (!topology.SerializationVerified) { return Result(RecoveryPlanOutcome.BlockedSerializationFailure, Math.Max(request.VanillaOccupiedSlots, topology.OccupiedSlots), topology.RecoveryCapacitySlots, correlation, "verify-peer-topology-serialization"); } int num2 = Math.Max(request.VanillaOccupiedSlots, topology.OccupiedSlots); int num3 = Math.Max(num, topology.RecoveryCapacitySlots); if (num3 < num2) { return Result(RecoveryPlanOutcome.BlockedUnsupportedExternalTopology, num2, num3, correlation, "unsupported-external-inventory-topology"); } return Result(RecoveryPlanOutcome.SafeExpandedTombstone, num2, num3, correlation, "none"); } private RecoveryPlan Result(RecoveryPlanOutcome outcome, int required, int planned, string correlation, string remediation) { _diagnostics.Record(correlation, "recovery-plan", outcome.ToString().ToLowerInvariant(), (outcome != RecoveryPlanOutcome.SafeVanillaTombstone && outcome != RecoveryPlanOutcome.SafeExpandedTombstone) ? SafetyDiagnosticSeverity.Warning : SafetyDiagnosticSeverity.Information); return new RecoveryPlan(outcome, required, planned, correlation, remediation); } private static bool ValidTopology(InventoryTopologySnapshot snapshot) { if (!string.IsNullOrWhiteSpace(snapshot.ProviderId) && !string.IsNullOrWhiteSpace(snapshot.TopologyHash) && snapshot.TotalSlots >= 0 && snapshot.OccupiedSlots >= 0 && snapshot.OccupiedSlots <= snapshot.TotalSlots) { return snapshot.RecoveryCapacitySlots >= 0; } return false; } private static bool CompatibleProtocol(string value) { if (TryReadMajor(value, out var major) && TryReadMajor("1.0", out var major2)) { return major == major2; } return false; } private static bool TryReadMajor(string value, out int major) { major = 0; if (string.IsNullOrWhiteSpace(value)) { return false; } string[] array = value.Trim().Split('.'); if (array.Length >= 1 && int.TryParse(array[0], out major)) { return major >= 0; } return false; } private void Unregister(long token) { lock (_sync) { _providers.RemoveAll((ProviderEntry entry) => entry.Token == token); } } private static string Bound(string value, string fallback) { if (string.IsNullOrWhiteSpace(value)) { return fallback; } string text = value.Trim(); if (text.Length > 64) { return text.Substring(0, 64); } return text; } } } namespace RunicSafety.Integration { internal static class LocalizationBridge { private static readonly MethodInfo AddWord = typeof(Localization).GetMethod("AddWord", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[2] { typeof(string), typeof(string) }, null); private static readonly IReadOnlyDictionary<string, string> English = new Dictionary<string, string>(StringComparer.Ordinal) { ["runicsafety_confirm_container"] = "Runic Safety: repeat the same removal to destroy this occupied container.", ["runicsafety_confirm_vehicle"] = "Runic Safety: repeat the same removal to destroy this ship or cart.", ["runicsafety_confirm_portal"] = "Runic Safety: submit the same tag again to overwrite this portal.", ["runicsafety_confirm_rare"] = "Runic Safety: repeat the same action to use the protected rare item.", ["runicsafety_protected_denied"] = "Runic Safety blocked a protected item from this destination.", ["runicsafety_provider_missing"] = "Runic Safety blocked the action because the installed inventory protection provider is unavailable.", ["runicsafety_recovery_unsafe"] = "Runic Safety could not verify expanded death recovery; see the correlated log before migrating topology." }; internal static bool Validate(out string problem) { if (AddWord == null) { problem = "Localization.AddWord(string,string) is missing."; return false; } problem = string.Empty; return true; } internal static void Install(Localization localization) { if (localization == null || AddWord == null) { return; } foreach (KeyValuePair<string, string> item in English) { AddWord.Invoke(localization, new object[2] { item.Key, item.Value }); } } } [HarmonyPatch(typeof(Localization), "SetupLanguage", new Type[] { typeof(string) })] internal static class LocalizationSetupLanguagePatch { [HarmonyPostfix] private static void Postfix(Localization __instance) { LocalizationBridge.Install(__instance); } } [HarmonyPatch(typeof(Player), "RemovePiece", new Type[] { })] internal static class PlayerRemovePiecePatch { [HarmonyPrefix] [HarmonyPriority(800)] private static bool Prefix(Player __instance) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizePieceRemoval(__instance); } return true; } } [HarmonyPatch(typeof(TeleportWorld), "SetText", new Type[] { typeof(string) })] [HarmonyAfter(new string[] { "chazman.RunicInteraction" })] internal static class TeleportWorldSetTextPatch { [HarmonyPrefix] private static bool Prefix(TeleportWorld __instance, string text) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizePortalOverwrite(__instance, text); } return true; } } [HarmonyPatch(typeof(Incinerator), "OnIncinerate", new Type[] { typeof(Switch), typeof(Humanoid), typeof(ItemData) })] internal static class IncineratorOnIncineratePatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicInventory" })] private static bool Prefix(Incinerator __instance, Humanoid user) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeIncineratorClient(__instance, user); } return true; } } [HarmonyPatch(typeof(Incinerator), "RPC_RequestIncinerate", new Type[] { typeof(long), typeof(long) })] internal static class IncineratorRequestPatch { [HarmonyPrefix] [HarmonyPriority(800)] private static bool Prefix(Incinerator __instance, long uid) { if (!Plugin.RuntimeReady || Plugin.CurrentRuntime.AuthorizeIncineratorOwner(__instance, uid)) { return true; } ValheimContracts.SendIncineratorFailure(__instance, uid); return false; } } [HarmonyPatch(typeof(Smelter), "OnAddOre", new Type[] { typeof(Switch), typeof(Humanoid), typeof(ItemData) })] internal static class SmelterAddOrePatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicInventory" })] private static bool Prefix(Smelter __instance, Humanoid user, ItemData item) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeSmelterOre(__instance, user, item); } return true; } } [HarmonyPatch(typeof(Smelter), "OnAddFuel", new Type[] { typeof(Switch), typeof(Humanoid), typeof(ItemData) })] internal static class SmelterAddFuelPatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicInventory" })] private static bool Prefix(Smelter __instance, Humanoid user, ItemData item) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeSmelterFuel(__instance, user, item); } return true; } } [HarmonyPatch(typeof(CookingStation), "OnAddFuelSwitch", new Type[] { typeof(Switch), typeof(Humanoid), typeof(ItemData) })] internal static class CookingStationAddFuelPatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicInventory", "chazman.RunicProduction" })] private static bool Prefix(CookingStation __instance, Humanoid user, ItemData item) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeCookingFuel(__instance, user, item); } return true; } } [HarmonyPatch(typeof(CookingStation), "OnUseItem", new Type[] { typeof(Humanoid), typeof(ItemData) })] internal static class CookingStationUseItemPatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicProduction" })] private static bool Prefix(CookingStation __instance, Humanoid user, ItemData item) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeCookingFood(__instance, user, item); } return true; } } [HarmonyPatch(typeof(Fermenter), "AddItem", new Type[] { typeof(Humanoid), typeof(ItemData) })] internal static class FermenterAddItemPatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicInventory", "chazman.RunicProduction" })] private static bool Prefix(Fermenter __instance, Humanoid user, ItemData item) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeFermenter(__instance, user, item); } return true; } } [HarmonyPatch(typeof(ItemStand), "UseItem", new Type[] { typeof(Humanoid), typeof(ItemData) })] internal static class ItemStandUseItemPatch { [HarmonyPrefix] [HarmonyPriority(800)] [HarmonyBefore(new string[] { "chazman.RunicInventory" })] private static bool Prefix(ItemStand __instance, Humanoid user, ItemData item) { if (Plugin.RuntimeReady) { return Plugin.CurrentRuntime.AuthorizeItemStand(__instance, user, item); } return true; } } [HarmonyPatch(typeof(Player), "CreateTombStone", new Type[] { })] internal static class PlayerCreateTombstonePatch { [HarmonyPrefix] private static void Prefix(Player __instance, out TombstoneAuditState __state) { __state = (Plugin.RuntimeReady ? Plugin.CurrentRuntime.BeginTombstoneAudit(__instance) : default(TombstoneAuditState)); } [HarmonyPostfix] private static void Postfix(Player __instance, TombstoneAuditState __state) { if (Plugin.RuntimeReady) { Plugin.CurrentRuntime.CompleteTombstoneAudit(__instance, __state); } } } internal sealed class SafetyRuntime : ISafetyStatusService { private readonly struct AggregateProtection { internal bool Denied { get; } internal bool ConfirmationRequired { get; } internal ProtectionReason Reason { get; } internal AggregateProtection(bool denied, bool confirmationRequired, ProtectionReason reason) { Denied = denied; ConfirmationRequired = confirmationRequired; Reason = reason; } } private static readonly IReadOnlyList<string> PermanentGates = Array.AsReadOnly(new string[1] { "unsupported-destination.interception:policy-only-consumer-registration-required" }); private readonly CorrelatedDiagnosticBuffer _diagnostics; private readonly ContextualConfirmationService _confirmations; private readonly ProtectedItemPolicy _protection; private readonly RecoveryPlanningService _recovery; private readonly MigrationBackupService _backups; private readonly CompatibilityGate _compatibility; internal IContextualConfirmationService Confirmations => _confirmations; internal IProtectedItemPolicy Protection => _protection; internal IRecoveryPlanningService Recovery => _recovery; internal IMigrationBackupService Backups => _backups; internal ICompatibilityGate Compatibility => _compatibility; internal ISafetyDiagnosticService DiagnosticService => _diagnostics; public bool IsOperational { get { if (Plugin.RuntimeReady) { return SafetyConfig.Enabled?.Value ?? false; } return false; } } public bool InventoryTopologyProviderAttached => _recovery.HasTopologyProvider; public IReadOnlyList<string> DisabledGates => PermanentGates; internal SafetyRuntime(CorrelatedDiagnosticBuffer diagnostics) { _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); _confirmations = new ContextualConfirmationService(_diagnostics); _protection = new ProtectedItemPolicy(_diagnostics, () => SafetyConfig.ConfirmRareSacrifice?.Value ?? true, () => SafetyConfig.AdministratorBypass?.Value ?? false); _recovery = new RecoveryPlanningService(_diagnostics); _backups = new MigrationBackupService(_diagnostics); _compatibility = new CompatibilityGate(_diagnostics); } internal void Initialize() { LocalizationBridge.Install(Localization.instance); CompatibilityIdentity compatibilityIdentity = new CompatibilityIdentity("runic.safety", "1.0.0", "1.0", "0.221.12", "native-valheim", SafetyConfig.SynchronizedRulesHash()); CompatibilityDecision compatibilityDecision = _compatibility.Evaluate(compatibilityIdentity, compatibilityIdentity); if (!compatibilityDecision.MayEnter) { throw new InvalidOperationException("The local compatibility identity failed: " + compatibilityDecision.Outcome); } _diagnostics.Record(_diagnostics.NewCorrelationId("startup"), "startup", "services-ready"); } internal void OnConfigurationChanged() { _confirmations.Clear(); _diagnostics.Record(_diagnostics.NewCorrelationId("config"), "configuration", "refreshed"); } internal void Shutdown() { _confirmations.Clear(); } internal bool AuthorizePieceRemoval(Player player) { if ((Object)(object)player == (Object)null) { return true; } Piece hoveringPiece = player.GetHoveringPiece(); if ((Object)(object)hoveringPiece == (Object)null) { return true; } Container componentInChildren = ((Component)hoveringPiece).GetComponentInChildren<Container>(); if (!Enabled()) { return true; } bool num = HasVehicle(hoveringPiece); int? obj; if (componentInChildren == null) { obj = null; } else { Inventory inventory = componentInChildren.GetInventory(); obj = ((inventory != null) ? new int?(inventory.NrOfItems()) : ((int?)null)); } int? num2 = obj; int valueOrDefault = num2.GetValueOrDefault(); if (num) { ConfigEntry<bool> confirmVehicleDestruction = SafetyConfig.ConfirmVehicleDestruction; if (confirmVehicleDestruction == null || confirmVehicleDestruction.Value) { return ConfirmOrMessage(player, SafetyActionKind.VehicleDestruction, "piece:" + ObjectKey((Component)(object)hoveringPiece), HashState("vehicle", valueOrDefault.ToString(CultureInfo.InvariantCulture), ContainerRevision(componentInChildren)), "$runicsafety_confirm_vehicle"); } } if (valueOrDefault > 0) { ConfigEntry<bool> confirmOccupiedContainer = SafetyConfig.ConfirmOccupiedContainer; if (confirmOccupiedContainer == null || confirmOccupiedContainer.Value) { return ConfirmOrMessage(player, SafetyActionKind.OccupiedContainerDestruction, "piece:" + ObjectKey((Component)(object)hoveringPiece), HashState("container", valueOrDefault.ToString(CultureInfo.InvariantCulture), componentInChildren.GetInventory().NrOfItemsIncludingStacks().ToString(CultureInfo.InvariantCulture), ContainerRevision(componentInChildren)), "$runicsafety_confirm_container"); } } return true; } internal bool AuthorizePortalOverwrite(TeleportWorld portal, string newText) { if (Enabled()) { ConfigEntry<bool> confirmPortalOverwrite = SafetyConfig.ConfirmPortalOverwrite; if ((confirmPortalOverwrite == null || confirmPortalOverwrite.Value) && !((Object)(object)portal == (Object)null)) { string text = portal.GetText() ?? string.Empty; string text2 = newText ?? string.Empty; if (text.Length == 0 || string.Equals(text, text2, StringComparison.Ordinal)) { return true; } if (_confirmations.Evaluate(new ConfirmationRequest(SafetyActionKind.PortalOverwrite, "portal:" + ObjectKey((Component)(object)portal), HashState(text, text2), SafetyConfig.ConfirmationWindow), DateTime.UtcNow).MayProceed) { return true; } Player localPlayer = Player.m_localPlayer; if (localPlayer != null) { ((Character)localPlayer).Message((MessageType)2, "$runicsafety_confirm_portal", 0, (Sprite)null); } return false; } } return true; } internal bool AuthorizeSmelterOre(Smelter station, Humanoid user, ItemData item) { if (!Enabled() || !ProtectionEnabled()) { return true; } ItemData item2 = item ?? FindSmelterItem(station, (user != null) ? user.GetInventory() : null); return AuthorizeItem(item2, ProtectionDestination.SmelterInput, (Component)(object)station, user); } internal bool AuthorizeSmelterFuel(Smelter station, Humanoid user, ItemData item) { if (!Enabled() || !ProtectionEnabled()) { return true; } ItemData item2 = item ?? FindNamedItem((user != null) ? user.GetInventory() : null, station?.m_fuelItem); return AuthorizeItem(item2, ProtectionDestination.SmelterFuel, (Component)(object)station, user); } internal bool AuthorizeCookingFood(CookingStation station, Humanoid user, ItemData item) { if (!Enabled() || !ProtectionEnabled()) { return true; } ItemData item2 = item ?? FindCookingItem(station, (user != null) ? user.GetInventory() : null); return AuthorizeItem(item2, ProtectionDestination.CookingStation, (Component)(object)station, user); } internal bool AuthorizeCookingFuel(CookingStation station, Humanoid user, ItemData item) { if (!Enabled() || !ProtectionEnabled()) { return true; } ItemData item2 = item ?? FindNamedItem((user != null) ? user.GetInventory() : null, station?.m_fuelItem); return AuthorizeItem(item2, ProtectionDestination.CookingFuel, (Component)(object)station, user); } internal bool AuthorizeFermenter(Fermenter station, Humanoid user, ItemData item) { if (!Enabled() || !ProtectionEnabled()) { return true; } return AuthorizeItem(item, ProtectionDestination.Fermenter, (Component)(object)station, user); } internal bool AuthorizeItemStand(ItemStand stand, Humanoid user, ItemData item) { if (!Enabled() || !ProtectionEnabled()) { return true; } return AuthorizeItem(item, ProtectionDestination.ItemStand, (Component)(object)stand, user); } internal bool AuthorizeIncineratorClient(Incinerator incinerator, Humanoid user) { if (Enabled() && ProtectionEnabled()) { object obj; if (incinerator == null) { obj = null; } else { Container container = incinerator.m_container; obj = ((container != null) ? container.GetInventory() : null); } if (obj != null) { Inventory inventory = incinerator.m_container.GetInventory(); AggregateProtection aggregateProtection = EvaluateInventory(inventory, ProtectionDestination.Obliterator, IsLocalAdministrator()); if (aggregateProtection.Denied) { ShowProtectionMessage(user, aggregateProtection.Reason); return false; } if (!aggregateProtection.ConfirmationRequired) { return true; } if (!_confirmations.Evaluate(new ConfirmationRequest(SafetyActionKind.RareItemSacrifice, "client-incinerator:" + ObjectKey((Component)(object)incinerator), InventoryFingerprint(inventory), SafetyConfig.ConfirmationWindow), DateTime.UtcNow).MayProceed && user != null) { ((Character)user).Message((MessageType)2, "$runicsafety_confirm_rare", 0, (Sprite)null); } return true; } } return true; } internal bool AuthorizeIncineratorOwner(Incinerator incinerator, long sender) { if (Enabled() && ProtectionEnabled()) { object obj; if (incinerator == null) { obj = null; } else { Container container = incinerator.m_container; obj = ((container != null) ? container.GetInventory() : null); } if (obj != null) { Inventory inventory = incinerator.m_container.GetInventory(); AggregateProtection aggregateProtection = EvaluateInventory(inventory, ProtectionDestination.Obliterator, IsSenderAdministrator(sender)); if (aggregateProtection.Denied) { return false; } if (!aggregateProtection.ConfirmationRequired) { return true; } return _confirmations.Evaluate(new ConfirmationRequest(SafetyActionKind.RareItemSacrifice, "owner-incinerator:" + sender.ToString(CultureInfo.InvariantCulture) + ":" + ObjectKey((Component)(object)incinerator), InventoryFingerprint(inventory), SafetyConfig.ConfirmationWindow), DateTime.UtcNow).MayProceed; } } return true; } internal TombstoneAuditState BeginTombstoneAudit(Player player) { if (!Enabled() || (Object)(object)player == (Object)null || ((Humanoid)player).GetInventory() == null) { return default(TombstoneAuditState); } Inventory inventory = ((Humanoid)player).GetInventory(); int bytes; bool vanillaSerializationVerified = VerifySerialization(inventory, out bytes); long playerID = player.GetPlayerID(); InventoryTopologySnapshot snapshot = null; if (_recovery.HasTopologyProvider && !_recovery.TryCaptureTopology(playerID, out snapshot, out var failureCode)) { snapshot = new InventoryTopologySnapshot("missing-adapter", "invalid", 0, 0, 0, serializationVerified: false, "missing"); _diagnostics.Record(_diagnostics.NewCorrelationId("topology"), "topology", BoundCode(failureCode, "provider-unavailable"), SafetyDiagnosticSeverity.Warning); } RecoveryPlan plan = _recovery.Plan(new RecoveryPlanningRequest(playerID, inventory.GetWidth(), inventory.GetHeight(), inventory.NrOfItems(), vanillaSerializationVerified, snapshot)); return new TombstoneAuditState(active: true, inventory.NrOfItems(), bytes, plan); } internal void CompleteTombstoneAudit(Player player, TombstoneAuditState state) { if (state.Active) { _diagnostics.Record(state.Plan.CorrelationId, "tombstone", state.Plan.IsLosslessPlan ? "vanilla-call-completed" : "recovery-plan-unresolved", (!state.Plan.IsLosslessPlan) ? SafetyDiagnosticSeverity.Warning : Safety