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 _rarePrefabs = new HashSet(StringComparer.Ordinal); private static ConfigFile _config; internal static ConfigEntry Enabled { get; private set; } internal static ConfigEntry ConfirmRareSacrifice { get; private set; } internal static ConfigEntry ConfirmOccupiedContainer { get; private set; } internal static ConfigEntry ConfirmVehicleDestruction { get; private set; } internal static ConfigEntry ConfirmPortalOverwrite { get; private set; } internal static ConfigEntry ConfirmationWindowSeconds { get; private set; } internal static ConfigEntry ProtectedDestinations { get; private set; } internal static ConfigEntry AdministratorBypass { get; private set; } internal static ConfigEntry RarePrefabNames { get; private set; } internal static ConfigEntry BackupRoot { get; private set; } internal static ConfigEntry BackupRetention { get; private set; } internal static ConfigEntry BackupMaximumFiles { get; private set; } internal static ConfigEntry 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(1f, 15f), Array.Empty())); 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(1, 128), Array.Empty())); BackupMaximumFiles = Bind(config, "Migration Backups", "MaximumFiles", 32, new ConfigDescription("Maximum source files in one backup transaction.", (AcceptableValueBase)(object)new AcceptableValueRange(1, 1024), Array.Empty())); BackupMaximumMiB = Bind(config, "Migration Backups", "MaximumTotalMiB", 2048, new ConfigDescription("Maximum total source bytes in one backup transaction.", (AcceptableValueBase)(object)new AcceptableValueRange(1, 65536), Array.Empty())); RebuildRarePrefabs(); config.SettingChanged += OnSettingChanged; } internal static void Unbind() { if (_config != null) { _config.SettingChanged -= OnSettingChanged; } _config = null; lock (Sync) { _rarePrefabs = new HashSet(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(_rarePrefabs).OrderBy((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 Bind(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())); } private static ConfigEntry Bind(ConfigFile config, string section, string key, T value, ConfigDescription description) { return config.Bind(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 hashSet = new HashSet(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 _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 available) { _remoteAdmissionAvailable = available ?? ((Func)(() => false)); } public CompatibilityDecision Evaluate(CompatibilityIdentity local, CompatibilityIdentity remote, IEnumerable 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 _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(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 list = new List(); 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 list = new List(); foreach (KeyValuePair 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 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 _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 clock = null, ManualLogSource log = null) { if (capacity < 8 || capacity > 4096) { throw new ArgumentOutOfRangeException("capacity"); } _events = new SafetyDiagnosticEvent[capacity]; _clock = clock ?? ((Func)(() => 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 Snapshot() { lock (_sync) { List list = new List(_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 ActiveBackupRoots = new HashSet(RootPathComparer); private readonly IBackupStorage _storage; private readonly ISafetyDiagnosticService _diagnostics; private readonly Func _clock; private readonly Func _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 clock, Func defaults = null) { _storage = storage ?? throw new ArgumentNullException("storage"); _diagnostics = diagnostics ?? throw new ArgumentNullException("diagnostics"); _clock = clock ?? throw new ArgumentNullException("clock"); _defaults = defaults ?? ((Func)(() => new MigrationBackupDefaults(Path.Combine(Path.GetTempPath(), "RunicSafety", "backups"), 5, 32, 2147483648L))); } public MigrationBackupRequest CreateDefaultRequest(string migrationId, IEnumerable 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 list = new List(); 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 list2 = new List(request.Sources.Count); long num = 0L; HashSet hashSet = new HashSet(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()), 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 list = new List(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 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 hashSet = new HashSet(StringComparer.OrdinalIgnoreCase); Dictionary dictionary = null; if (expectedFiles != null) { if (expectedFiles.Count < 1 || expectedFiles.Count > 1024) { failureCode = "expected-file-set-invalid"; return false; } dictionary = new Dictionary(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 files = new List(); 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(), 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(), 0, 0); if (num != expectedLength) { throw new InvalidDataException("Backup file shrank during validation."); } return ToHex(sHA.Hash); } private void ProtectFile(ICollection 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 list = new List(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 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 sources) { List list = new List(); 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 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 files, string root, string partial) { CleanupPartial(root, partial); return Failure(outcome, correlation, code, files); } private MigrationBackupResult Failure(MigrationBackupOutcome outcome, string correlation, string code, List 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 _providers = new List(); private readonly ISafetyDiagnosticService _diagnostics; private readonly Func _rareConfirmationEnabled; private readonly Func _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 rareConfirmationEnabled, Func 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 _providers = new List(); 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 English = new Dictionary(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 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 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 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(); 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 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 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 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 : SafetyDiagnosticSeverity.Information); if (!state.Plan.IsLosslessPlan && player != null) { ((Character)player).Message((MessageType)1, "$runicsafety_recovery_unsafe", 0, (Sprite)null); } } } private bool AuthorizeItem(ItemData item, ProtectionDestination destination, Component target, Humanoid user) { if (item == null) { return true; } string correlationId = _diagnostics.NewCorrelationId("destination"); bool integrationPresent; ItemLockState lockState = InventoryProtectionAdapter.Resolve(item, out integrationPresent); ItemProtectionDecision itemProtectionDecision = _protection.Evaluate(new ItemProtectionRequest(Describe(item, lockState), destination, correlationId, IsLocalAdministrator(), integrationPresent, item)); if (itemProtectionDecision.Outcome == ProtectionOutcome.Allow) { return true; } if (itemProtectionDecision.Outcome == ProtectionOutcome.Deny) { ShowProtectionMessage(user, itemProtectionDecision.Reason); return false; } ContextualConfirmationService confirmations = _confirmations; int num = (int)destination; if (confirmations.Evaluate(new ConfirmationRequest(SafetyActionKind.ProtectedDestination, "destination:" + num.ToString(CultureInfo.InvariantCulture) + ":" + ObjectKey(target), ItemFingerprint(item), SafetyConfig.ConfirmationWindow), DateTime.UtcNow).MayProceed) { return true; } if (user != null) { ((Character)user).Message((MessageType)2, "$runicsafety_confirm_rare", 0, (Sprite)null); } return false; } private AggregateProtection EvaluateInventory(Inventory inventory, ProtectionDestination destination, bool administrator) { bool confirmationRequired = false; ProtectionReason reason = ProtectionReason.None; List allItems = inventory.GetAllItems(); for (int i = 0; i < allItems.Count; i++) { ItemData val = allItems[i]; bool integrationPresent; ItemLockState lockState = InventoryProtectionAdapter.Resolve(val, out integrationPresent); string correlationId = _diagnostics.NewCorrelationId("obliterate"); ItemProtectionDecision itemProtectionDecision = _protection.Evaluate(new ItemProtectionRequest(Describe(val, lockState), destination, correlationId, administrator, integrationPresent, val)); if (itemProtectionDecision.Outcome == ProtectionOutcome.Deny) { return new AggregateProtection(denied: true, confirmationRequired: false, itemProtectionDecision.Reason); } if (itemProtectionDecision.Outcome == ProtectionOutcome.RequireConfirmation) { confirmationRequired = true; reason = itemProtectionDecision.Reason; } } return new AggregateProtection(denied: false, confirmationRequired, reason); } private static ProtectedItemDescriptor Describe(ItemData item, ItemLockState lockState) { string text = (((Object)(object)item?.m_dropPrefab != (Object)null) ? ((Object)item.m_dropPrefab).name : (item?.m_shared?.m_name ?? string.Empty)); return new ProtectedItemDescriptor(text, item?.m_equipped ?? false, item?.m_shared?.m_questItem == true, lockState, SafetyConfig.IsRarePrefab(text)); } private static ItemData FindSmelterItem(Smelter station, Inventory inventory) { if ((Object)(object)station == (Object)null || inventory == null) { return null; } foreach (ItemConversion item2 in station.m_conversion) { ItemDrop val = item2?.m_from; if (!((Object)(object)val == (Object)null)) { ItemData item = inventory.GetItem(val.m_itemData.m_shared.m_name, -1, false); if (item != null) { return item; } } } return null; } private static ItemData FindCookingItem(CookingStation station, Inventory inventory) { if ((Object)(object)station == (Object)null || inventory == null) { return null; } foreach (ItemConversion item2 in station.m_conversion) { ItemDrop val = item2?.m_from; if (!((Object)(object)val == (Object)null)) { ItemData item = inventory.GetItem(val.m_itemData.m_shared.m_name, -1, false); if (item != null) { return item; } } } return null; } private static ItemData FindNamedItem(Inventory inventory, ItemDrop source) { if (inventory == null || (Object)(object)source == (Object)null) { return null; } return inventory.GetItem(source.m_itemData.m_shared.m_name, -1, false); } private static bool VerifySerialization(Inventory inventory, out int bytes) { //IL_0003: Unknown result type (might be due to invalid IL or missing references) //IL_0009: Expected O, but got Unknown bytes = 0; try { ZPackage val = new ZPackage(); inventory.Save(val); bytes = val.Size(); return bytes > 0; } catch (Exception) { return false; } } private bool ConfirmOrMessage(Player player, SafetyActionKind action, string context, string fingerprint, string message) { if (_confirmations.Evaluate(new ConfirmationRequest(action, context, fingerprint, SafetyConfig.ConfirmationWindow), DateTime.UtcNow).MayProceed) { return true; } ((Character)player).Message((MessageType)2, message, 0, (Sprite)null); return false; } private static bool HasVehicle(Piece piece) { if (!((Object)(object)((Component)piece).GetComponentInParent() != (Object)null) && !((Object)(object)((Component)piece).GetComponentInChildren() != (Object)null) && !((Object)(object)((Component)piece).GetComponentInParent() != (Object)null)) { return (Object)(object)((Component)piece).GetComponentInChildren() != (Object)null; } return true; } private static string ContainerRevision(Container container) { try { ZNetView obj = ((container != null) ? ((Component)container).GetComponent() : null); object obj2; if (obj == null) { obj2 = null; } else { ZDO zDO = obj.GetZDO(); obj2 = ((zDO != null) ? zDO.DataRevision.ToString(CultureInfo.InvariantCulture) : null); } if (obj2 == null) { obj2 = "0"; } return (string)obj2; } catch (Exception) { return "0"; } } private static string ObjectKey(Component component) { if ((Object)(object)component == (Object)null) { return "none"; } try { ZNetView obj = component.GetComponent() ?? component.GetComponentInParent(); ZDO val = ((obj != null) ? obj.GetZDO() : null); if (val != null) { return ((object)Unsafe.As(ref val.m_uid)/*cast due to .constrained prefix*/).ToString(); } } catch (Exception) { } return ((Object)component).GetInstanceID().ToString(CultureInfo.InvariantCulture); } private static string ItemFingerprint(ItemData item) { string[] array = new string[4]; object obj; if (item == null) { obj = null; } else { GameObject dropPrefab = item.m_dropPrefab; obj = ((dropPrefab != null) ? ((Object)dropPrefab).name : null); } if (obj == null) { obj = item?.m_shared?.m_name ?? string.Empty; } array[0] = (string)obj; array[1] = (item?.m_stack ?? 0).ToString(CultureInfo.InvariantCulture); array[2] = (item?.m_quality ?? 0).ToString(CultureInfo.InvariantCulture); array[3] = (item?.m_variant ?? 0).ToString(CultureInfo.InvariantCulture); return HashState(array); } private static string InventoryFingerprint(Inventory inventory) { ulong hash = 14695981039346656037uL; List allItems = inventory.GetAllItems(); for (int i = 0; i < allItems.Count; i++) { string value = ItemFingerprint(allItems[i]); AppendHash(ref hash, value); } AppendHash(ref hash, allItems.Count.ToString(CultureInfo.InvariantCulture)); return hash.ToString("x16", CultureInfo.InvariantCulture); } private static string HashState(params string[] values) { ulong hash = 14695981039346656037uL; foreach (string text in values) { AppendHash(ref hash, text ?? string.Empty); } return hash.ToString("x16", CultureInfo.InvariantCulture); } private static void AppendHash(ref ulong hash, string value) { foreach (char c in value) { hash ^= (byte)c; hash *= 1099511628211uL; hash ^= (byte)((int)c >> 8); hash *= 1099511628211uL; } hash ^= 255uL; hash *= 1099511628211uL; } private static bool Enabled() { return SafetyConfig.Enabled?.Value ?? false; } private static bool ProtectionEnabled() { return SafetyConfig.ProtectedDestinations?.Value ?? true; } private static bool IsLocalAdministrator() { try { return (Object)(object)ZNet.instance != (Object)null && ZNet.instance.LocalPlayerIsAdminOrHost(); } catch (Exception) { return false; } } private static bool IsSenderAdministrator(long sender) { try { return (Object)(object)ZNet.instance != (Object)null && ZNet.instance.IsServer() && sender == ZNet.GetUID() && ZNet.instance.LocalPlayerIsAdminOrHost(); } catch (Exception) { return false; } } private static void ShowProtectionMessage(Humanoid user, ProtectionReason reason) { string text = ((reason == ProtectionReason.ProviderUnavailable) ? "$runicsafety_provider_missing" : "$runicsafety_protected_denied"); if (user != null) { ((Character)user).Message((MessageType)2, text, 0, (Sprite)null); } } private static string BoundCode(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; } } internal readonly struct TombstoneAuditState { internal bool Active { get; } internal int ItemCount { get; } internal int SerializedBytes { get; } internal RecoveryPlan Plan { get; } internal TombstoneAuditState(bool active, int itemCount, int serializedBytes, RecoveryPlan plan) { Active = active; ItemCount = itemCount; SerializedBytes = serializedBytes; Plan = plan; } } internal static class ValheimContracts { internal const string AuditedGameVersion = "0.221.12"; private const BindingFlags AllMethods = BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic; internal static readonly FieldInfo IncineratorNetView = typeof(Incinerator).GetField("m_nview", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); internal static bool Initialize(out string problem) { if (!Require(typeof(Player), "RemovePiece", Type.EmptyTypes, out problem) || !Require(typeof(Player), "CreateTombStone", Type.EmptyTypes, out problem) || !Require(typeof(TeleportWorld), "SetText", new Type[1] { typeof(string) }, out problem) || !Require(typeof(Incinerator), "OnIncinerate", new Type[3] { typeof(Switch), typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(Incinerator), "RPC_RequestIncinerate", new Type[2] { typeof(long), typeof(long) }, out problem) || !Require(typeof(Smelter), "OnAddOre", new Type[3] { typeof(Switch), typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(Smelter), "OnAddFuel", new Type[3] { typeof(Switch), typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(CookingStation), "OnAddFuelSwitch", new Type[3] { typeof(Switch), typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(CookingStation), "OnUseItem", new Type[2] { typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(Fermenter), "AddItem", new Type[2] { typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(ItemStand), "UseItem", new Type[2] { typeof(Humanoid), typeof(ItemData) }, out problem) || !Require(typeof(Localization), "SetupLanguage", new Type[1] { typeof(string) }, out problem)) { return false; } if (IncineratorNetView == null || IncineratorNetView.FieldType != typeof(ZNetView)) { problem = "Incinerator.m_nview:ZNetView is missing."; return false; } if (!LocalizationBridge.Validate(out problem)) { return false; } string text = ReadGameVersion(); if (!string.Equals(text, "0.221.12", StringComparison.Ordinal)) { problem = "Runic Safety was audited for Valheim 0.221.12 but the loaded assembly reports " + ((text.Length == 0) ? "unknown" : text) + "."; return false; } problem = string.Empty; return true; } internal static string ReadGameVersion() { try { return ((typeof(Player).Assembly.GetType("Version", throwOnError: false)?.GetProperty("CurrentVersion", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic))?.GetValue(null, null))?.ToString() ?? string.Empty; } catch (Exception) { return string.Empty; } } internal static void SendIncineratorFailure(Incinerator incinerator, long receiver) { try { object? obj = IncineratorNetView?.GetValue(incinerator); ZNetView val = (ZNetView)((obj is ZNetView) ? obj : null); if ((Object)(object)val != (Object)null && val.IsValid()) { val.InvokeRPC(receiver, "RPC_IncinerateRespons", new object[1] { 0 }); } } catch (Exception) { } } private static bool Require(Type type, string name, Type[] parameters, out string problem) { if (type.GetMethod(name, BindingFlags.Instance | BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic, null, parameters, null) == null) { string[] obj = new string[6] { type.FullName, ".", name, "(", null, null }; obj[4] = string.Join(",", (object?[])parameters); obj[5] = ") is missing."; problem = string.Concat(obj); return false; } problem = string.Empty; return true; } } } namespace RunicSafety.Api { public sealed class MigrationBackupSource { public string SourcePath { get; } public string LogicalName { get; } public MigrationBackupSource(string sourcePath, string logicalName) { SourcePath = sourcePath ?? string.Empty; LogicalName = logicalName ?? string.Empty; } } public sealed class MigrationBackupRequest { public string MigrationId { get; } public IReadOnlyList Sources { get; } public string DestinationRoot { get; } public int RetentionCount { get; } public long MaximumTotalBytes { get; } public int MaximumFiles { get; } public MigrationBackupRequest(string migrationId, IEnumerable sources, string destinationRoot, int retentionCount, long maximumTotalBytes, int maximumFiles) { MigrationId = migrationId ?? string.Empty; IReadOnlyList readOnlyList2; if (sources != null) { IReadOnlyList readOnlyList = new List(sources).AsReadOnly(); readOnlyList2 = readOnlyList; } else { IReadOnlyList readOnlyList = Array.Empty(); readOnlyList2 = readOnlyList; } Sources = readOnlyList2; DestinationRoot = destinationRoot ?? string.Empty; RetentionCount = retentionCount; MaximumTotalBytes = maximumTotalBytes; MaximumFiles = maximumFiles; } } public enum MigrationBackupOutcome { Succeeded, InvalidRequest, SourceMissing, SourceChangedDuringCopy, SizeLimitExceeded, FileLimitExceeded, IoFailure, Cancelled } public sealed class MigrationBackupFile { public string LogicalName { get; } public string OriginalPath { get; } public string BackupFileName { get; } public long Length { get; } public string Sha256 { get; } internal MigrationBackupFile(string logicalName, string originalPath, string backupFileName, long length, string sha256) { LogicalName = logicalName; OriginalPath = originalPath; BackupFileName = backupFileName; Length = length; Sha256 = sha256; } } public sealed class MigrationBackupResult { public MigrationBackupOutcome Outcome { get; } public string BackupDirectory { get; } public string CorrelationId { get; } public string FailureCode { get; } public IReadOnlyList Files { get; } public bool Succeeded => Outcome == MigrationBackupOutcome.Succeeded; internal MigrationBackupResult(MigrationBackupOutcome outcome, string backupDirectory, string correlationId, string failureCode, IReadOnlyList files) { Outcome = outcome; BackupDirectory = backupDirectory ?? string.Empty; CorrelationId = correlationId ?? string.Empty; FailureCode = failureCode ?? string.Empty; Files = files ?? Array.Empty(); } } public sealed class MigrationExecutionResult { public MigrationBackupResult Backup { get; } public bool MutationInvoked { get; } public Exception MutationFailure { get; } public bool Succeeded { get { if (Backup != null && Backup.Succeeded && MutationInvoked) { return MutationFailure == null; } return false; } } internal MigrationExecutionResult(MigrationBackupResult backup, bool mutationInvoked, Exception mutationFailure) { Backup = backup; MutationInvoked = mutationInvoked; MutationFailure = mutationFailure; } } public interface IMigrationBackupService { string DefaultDestinationRoot { get; } int DefaultRetentionCount { get; } int DefaultMaximumFiles { get; } long DefaultMaximumTotalBytes { get; } MigrationBackupRequest CreateDefaultRequest(string migrationId, IEnumerable sources); MigrationBackupResult CreateBackup(MigrationBackupRequest request, CancellationToken cancellationToken); MigrationExecutionResult ExecuteAfterBackup(MigrationBackupRequest request, Action mutation, CancellationToken cancellationToken); bool ValidateBackup(string backupDirectory, out string failureCode); } public sealed class CompatibilityIdentity { public string ModuleId { get; } public string SemanticVersion { get; } public string ProtocolVersion { get; } public string GameVersion { get; } public string TopologyHash { get; } public string SynchronizedRulesHash { get; } public CompatibilityIdentity(string moduleId, string semanticVersion, string protocolVersion, string gameVersion, string topologyHash, string synchronizedRulesHash) { ModuleId = moduleId ?? string.Empty; SemanticVersion = semanticVersion ?? string.Empty; ProtocolVersion = protocolVersion ?? string.Empty; GameVersion = gameVersion ?? string.Empty; TopologyHash = topologyHash ?? string.Empty; SynchronizedRulesHash = synchronizedRulesHash ?? string.Empty; } } public enum CompatibilityOutcome { Compatible, BlockedInvalidIdentity, BlockedGameVersion, BlockedProtocol, BlockedTopology, BlockedSynchronizedRules, BlockedKnownCombination } public sealed class KnownUnsafeCombination { public string LocalModuleId { get; } public string LocalVersion { get; } public string RemoteModuleId { get; } public string RemoteVersion { get; } public string RemediationCode { get; } public KnownUnsafeCombination(string localModuleId, string localVersion, string remoteModuleId, string remoteVersion, string remediationCode) { LocalModuleId = localModuleId ?? string.Empty; LocalVersion = localVersion ?? string.Empty; RemoteModuleId = remoteModuleId ?? string.Empty; RemoteVersion = remoteVersion ?? string.Empty; RemediationCode = remediationCode ?? string.Empty; } } public sealed class CompatibilityDecision { public CompatibilityOutcome Outcome { get; } public string RemediationCode { get; } public string CorrelationId { get; } public bool MayEnter => Outcome == CompatibilityOutcome.Compatible; internal CompatibilityDecision(CompatibilityOutcome outcome, string remediationCode, string correlationId) { Outcome = outcome; RemediationCode = remediationCode ?? string.Empty; CorrelationId = correlationId ?? string.Empty; } } public interface ICompatibilityGate { bool RemoteAdmissionHookAvailable { get; } CompatibilityDecision Evaluate(CompatibilityIdentity local, CompatibilityIdentity remote, IEnumerable knownUnsafe = null); } public enum SafetyActionKind { RareItemSacrifice, OccupiedContainerDestruction, VehicleDestruction, PortalOverwrite, ProtectedDestination, ConfiguredHighImpactAction } public enum ConfirmationOutcome { Proceed, ConfirmAgain, Invalid } public sealed class ConfirmationRequest { public SafetyActionKind Action { get; } public string ContextKey { get; } public string StateFingerprint { get; } public TimeSpan Window { get; } public bool Enabled { get; } public ConfirmationRequest(SafetyActionKind action, string contextKey, string stateFingerprint, TimeSpan window, bool enabled = true) { Action = action; ContextKey = contextKey ?? string.Empty; StateFingerprint = stateFingerprint ?? string.Empty; Window = window; Enabled = enabled; } } public sealed class ConfirmationDecision { public ConfirmationOutcome Outcome { get; } public string CorrelationId { get; } public bool MayProceed => Outcome == ConfirmationOutcome.Proceed; internal ConfirmationDecision(ConfirmationOutcome outcome, string correlationId) { Outcome = outcome; CorrelationId = correlationId ?? string.Empty; } } public interface IContextualConfirmationService { int PendingCount { get; } int Capacity { get; } ConfirmationDecision Evaluate(ConfirmationRequest request, DateTime utcNow); void Cancel(string contextKey); void Clear(); } public enum SafetyDiagnosticSeverity { Information, Warning, Error } public sealed class SafetyDiagnosticEvent { public long Sequence { get; } public DateTime TimestampUtc { get; } public string CorrelationId { get; } public string Category { get; } public string Code { get; } public SafetyDiagnosticSeverity Severity { get; } internal SafetyDiagnosticEvent(long sequence, DateTime timestampUtc, string correlationId, string category, string code, SafetyDiagnosticSeverity severity) { Sequence = sequence; TimestampUtc = timestampUtc; CorrelationId = correlationId; Category = category; Code = code; Severity = severity; } } public interface ISafetyDiagnosticService { int Capacity { get; } int Count { get; } string NewCorrelationId(string category); void Record(string correlationId, string category, string code, SafetyDiagnosticSeverity severity = SafetyDiagnosticSeverity.Information); IReadOnlyList Snapshot(); } public enum ProtectionDestination { Obliterator, SmelterInput, SmelterFuel, CookingStation, CookingFuel, Fermenter, ItemStand, UnsupportedDestructivePath } public enum ItemLockState { NotApplicable, Unlocked, Locked, Unknown } public enum ProtectionOutcome { Allow, RequireConfirmation, Deny } public enum ProtectionReason { None, Equipped, Locked, QuestItem, ConfiguredRareItem, ProviderUnavailable, ProviderFailure, ExternalPolicy, AdministratorBypass, InvalidRequest } public sealed class ProtectedItemDescriptor { public string StableItemId { get; } public bool Equipped { get; } public bool QuestItem { get; } public ItemLockState LockState { get; } public bool ConfiguredRare { get; } public ProtectedItemDescriptor(string stableItemId, bool equipped, bool questItem, ItemLockState lockState, bool configuredRare) { StableItemId = stableItemId ?? string.Empty; Equipped = equipped; QuestItem = questItem; LockState = lockState; ConfiguredRare = configuredRare; } } public sealed class ItemProtectionRequest { public ProtectedItemDescriptor Item { get; } public ProtectionDestination Destination { get; } public string CorrelationId { get; } public bool Administrator { get; } public bool ExternalInventoryCapabilityAdvertised { get; } public object NativeItemHandle { get; } public ItemProtectionRequest(ProtectedItemDescriptor item, ProtectionDestination destination, string correlationId, bool administrator, bool externalInventoryCapabilityAdvertised, object nativeItemHandle = null) { Item = item; Destination = destination; CorrelationId = correlationId ?? string.Empty; Administrator = administrator; ExternalInventoryCapabilityAdvertised = externalInventoryCapabilityAdvertised; NativeItemHandle = nativeItemHandle; } } public sealed class ItemProtectionDecision { public ProtectionOutcome Outcome { get; } public ProtectionReason Reason { get; } public string ProviderId { get; } public bool MayTransfer => Outcome != ProtectionOutcome.Deny; public ItemProtectionDecision(ProtectionOutcome outcome, ProtectionReason reason, string providerId = null) { Outcome = outcome; Reason = reason; ProviderId = providerId ?? string.Empty; } } public interface IItemProtectionProvider { string ProviderId { get; } ItemProtectionDecision Evaluate(ItemProtectionRequest request); } public interface IProtectedItemPolicy { bool HasExternalProvider { get; } int ProviderCount { get; } ItemProtectionDecision Evaluate(ItemProtectionRequest request); IDisposable RegisterProvider(IItemProtectionProvider provider, int priority = 0); } public sealed class InventoryTopologySnapshot { public string ProviderId { get; } public string ProtocolVersion { get; } public int TotalSlots { get; } public int OccupiedSlots { get; } public int RecoveryCapacitySlots { get; } public bool SerializationVerified { get; } public string TopologyHash { get; } public InventoryTopologySnapshot(string providerId, string protocolVersion, int totalSlots, int occupiedSlots, int recoveryCapacitySlots, bool serializationVerified, string topologyHash) { ProviderId = providerId ?? string.Empty; ProtocolVersion = protocolVersion ?? string.Empty; TotalSlots = totalSlots; OccupiedSlots = occupiedSlots; RecoveryCapacitySlots = recoveryCapacitySlots; SerializationVerified = serializationVerified; TopologyHash = topologyHash ?? string.Empty; } } public interface IInventoryTopologyProvider { string ProviderId { get; } bool TryCapture(long playerId, out InventoryTopologySnapshot snapshot, out string failureCode); } public sealed class RecoveryPlanningRequest { public long PlayerId { get; } public int VanillaWidth { get; } public int VanillaHeight { get; } public int VanillaOccupiedSlots { get; } public bool VanillaSerializationVerified { get; } public InventoryTopologySnapshot Topology { get; } public RecoveryPlanningRequest(long playerId, int vanillaWidth, int vanillaHeight, int vanillaOccupiedSlots, bool vanillaSerializationVerified, InventoryTopologySnapshot topology = null) { PlayerId = playerId; VanillaWidth = vanillaWidth; VanillaHeight = vanillaHeight; VanillaOccupiedSlots = vanillaOccupiedSlots; VanillaSerializationVerified = vanillaSerializationVerified; Topology = topology; } } public enum RecoveryPlanOutcome { SafeVanillaTombstone, SafeExpandedTombstone, BlockedUnsupportedExternalTopology, BlockedIncompatibleTopology, BlockedSerializationFailure, Invalid } public sealed class RecoveryPlan { public RecoveryPlanOutcome Outcome { get; } public int RequiredSlots { get; } public int PlannedSlots { get; } public string CorrelationId { get; } public string RemediationCode { get; } public bool IsLosslessPlan { get { if (Outcome != RecoveryPlanOutcome.SafeVanillaTombstone) { return Outcome == RecoveryPlanOutcome.SafeExpandedTombstone; } return true; } } internal RecoveryPlan(RecoveryPlanOutcome outcome, int requiredSlots, int plannedSlots, string correlationId, string remediationCode) { Outcome = outcome; RequiredSlots = requiredSlots; PlannedSlots = plannedSlots; CorrelationId = correlationId ?? string.Empty; RemediationCode = remediationCode ?? string.Empty; } } public interface IRecoveryPlanningService { bool HasTopologyProvider { get; } RecoveryPlan Plan(RecoveryPlanningRequest request); IDisposable RegisterTopologyProvider(IInventoryTopologyProvider provider); bool TryCaptureTopology(long playerId, out InventoryTopologySnapshot snapshot, out string failureCode); } public interface ISafetyStatusService { bool IsOperational { get; } bool InventoryTopologyProviderAttached { get; } IReadOnlyList DisabledGates { get; } } public static class SafetyIntegrationApi { private static readonly object Gate = new object(); private static SafetyRuntime _runtime; public static IContextualConfirmationService Confirmations { get { lock (Gate) { return _runtime?.Confirmations; } } } public static IProtectedItemPolicy Protection { get { lock (Gate) { return _runtime?.Protection; } } } public static IRecoveryPlanningService Recovery { get { lock (Gate) { return _runtime?.Recovery; } } } public static IMigrationBackupService Backups { get { lock (Gate) { return _runtime?.Backups; } } } public static ICompatibilityGate Compatibility { get { lock (Gate) { return _runtime?.Compatibility; } } } public static ISafetyDiagnosticService Diagnostics { get { lock (Gate) { return _runtime?.DiagnosticService; } } } public static ISafetyStatusService Status { get { lock (Gate) { return _runtime; } } } internal static void Attach(SafetyRuntime runtime) { if (runtime == null) { throw new ArgumentNullException("runtime"); } lock (Gate) { _runtime = runtime; } } internal static void Detach(SafetyRuntime runtime) { lock (Gate) { if (_runtime == runtime) { _runtime = null; } } } } }