feat(launcher): implement verified atomic updates

This commit is contained in:
Erik 2026-08-14 22:09:34 +02:00
parent 2198a0cc8e
commit 2d2a5b5046
34 changed files with 6755 additions and 61 deletions

View file

@ -0,0 +1,199 @@
using System.Diagnostics.CodeAnalysis;
namespace AcDream.Launcher.Core.Updates;
/// <summary>
/// Strict SemVer 2.0 value used by the release feed, client pointer, and
/// self-update plan. Numeric identifiers are compared as digit strings so a
/// maliciously large identifier cannot overflow a fixed-width integer.
/// </summary>
public sealed class LauncherVersion : IComparable<LauncherVersion>, IEquatable<LauncherVersion>
{
private readonly string[] _core;
private readonly string[] _preRelease;
private LauncherVersion(
string value,
string[] core,
string[] preRelease)
{
Value = value;
_core = core;
_preRelease = preRelease;
}
public string Value { get; }
public bool IsPreRelease => _preRelease.Length != 0;
public static LauncherVersion Parse(string value)
{
if (!TryParse(value, out LauncherVersion? version))
{
throw new FormatException($"'{value}' is not a strict SemVer 2.0 version.");
}
return version;
}
public static bool TryParse(
string? value,
[NotNullWhen(true)] out LauncherVersion? version)
{
version = null;
if (string.IsNullOrEmpty(value)
|| value.Length > 128
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal))
{
return false;
}
string precedence = value;
int plus = value.IndexOf('+', StringComparison.Ordinal);
if (plus >= 0)
{
if (plus == value.Length - 1
|| value.IndexOf('+', plus + 1) >= 0
|| !ValidIdentifiers(value[(plus + 1)..], numericLeadingZeroRule: false))
{
return false;
}
precedence = value[..plus];
}
string coreText = precedence;
string[] preRelease = [];
int dash = precedence.IndexOf('-', StringComparison.Ordinal);
if (dash >= 0)
{
if (dash == precedence.Length - 1
|| !ValidIdentifiers(precedence[(dash + 1)..], numericLeadingZeroRule: true))
{
return false;
}
coreText = precedence[..dash];
preRelease = precedence[(dash + 1)..].Split('.');
}
string[] core = coreText.Split('.');
if (core.Length != 3 || core.Any(part => !ValidCoreNumber(part)))
{
return false;
}
version = new LauncherVersion(value, core, preRelease);
return true;
}
public int CompareTo(LauncherVersion? other)
{
if (other is null)
{
return 1;
}
for (int index = 0; index < _core.Length; index++)
{
int comparison = CompareNumeric(_core[index], other._core[index]);
if (comparison != 0)
{
return comparison;
}
}
if (_preRelease.Length == 0 || other._preRelease.Length == 0)
{
return _preRelease.Length == other._preRelease.Length
? 0
: _preRelease.Length == 0 ? 1 : -1;
}
int shared = Math.Min(_preRelease.Length, other._preRelease.Length);
for (int index = 0; index < shared; index++)
{
string left = _preRelease[index];
string right = other._preRelease[index];
bool leftNumeric = IsDigits(left);
bool rightNumeric = IsDigits(right);
int comparison = leftNumeric && rightNumeric
? CompareNumeric(left, right)
: leftNumeric != rightNumeric
? leftNumeric ? -1 : 1
: string.Compare(left, right, StringComparison.Ordinal);
if (comparison != 0)
{
return comparison;
}
}
return _preRelease.Length.CompareTo(other._preRelease.Length);
}
public bool Equals(LauncherVersion? other) =>
other is not null && CompareTo(other) == 0;
public override bool Equals(object? obj) => Equals(obj as LauncherVersion);
public override int GetHashCode()
{
var hash = new HashCode();
foreach (string part in _core)
{
hash.Add(part, StringComparer.Ordinal);
}
hash.Add(_preRelease.Length);
foreach (string part in _preRelease)
{
hash.Add(part, StringComparer.Ordinal);
}
return hash.ToHashCode();
}
public override string ToString() => Value;
public static bool operator >(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) > 0;
public static bool operator <(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) < 0;
public static bool operator >=(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) >= 0;
public static bool operator <=(LauncherVersion left, LauncherVersion right) =>
left.CompareTo(right) <= 0;
private static bool ValidCoreNumber(string value) =>
IsDigits(value) && (value.Length == 1 || value[0] != '0');
private static bool ValidIdentifiers(string value, bool numericLeadingZeroRule)
{
string[] identifiers = value.Split('.');
return identifiers.All(identifier =>
identifier.Length > 0
&& identifier.All(character =>
character is >= '0' and <= '9'
or >= 'A' and <= 'Z'
or >= 'a' and <= 'z'
or '-')
&& (!numericLeadingZeroRule
|| !IsDigits(identifier)
|| identifier.Length == 1
|| identifier[0] != '0'));
}
private static bool IsDigits(string value) =>
value.Length > 0 && value.All(character => character is >= '0' and <= '9');
private static int CompareNumeric(string left, string right)
{
int length = left.Length.CompareTo(right.Length);
return length != 0
? length
: string.Compare(left, right, StringComparison.Ordinal);
}
}