GUI: harden cross-platform updater integrity and rollback (#389)

* GUI: verify updater releases by commit and SHA-256

* GUI: add updater rollback and version safeguards
This commit is contained in:
Mehmed Sinan Kömek
2026-07-18 14:41:59 +03:00
committed by GitHub
parent b3e3fe5ea8
commit e6be48a390
4 changed files with 213 additions and 37 deletions
+1
View File
@@ -169,5 +169,6 @@
"Updater.Status.Installing": "Installing update…", "Updater.Status.Installing": "Installing update…",
"Updater.Status.Timeout": "Update check timed out after 10 seconds.", "Updater.Status.Timeout": "Update check timed out after 10 seconds.",
"Updater.Status.Failed": "Could not check for updates.", "Updater.Status.Failed": "Could not check for updates.",
"Updater.Status.ChecksumFailed": "Downloaded update failed SHA-256 verification.",
"Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build." "Updater.Status.Unsupported": "Automatic updating requires a Windows, Linux or macOS x64 build."
} }
+1
View File
@@ -140,5 +140,6 @@
"Updater.Status.Installing": "Güncelleme kuruluyor…", "Updater.Status.Installing": "Güncelleme kuruluyor…",
"Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.", "Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.",
"Updater.Status.Failed": "Güncellemeler denetlenemedi.", "Updater.Status.Failed": "Güncellemeler denetlenemedi.",
"Updater.Status.ChecksumFailed": "İndirilen güncelleme SHA-256 doğrulamasını geçemedi.",
"Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir." "Updater.Status.Unsupported": "Otomatik güncelleme Windows, Linux veya macOS x64 build'i gerektirir."
} }
+5
View File
@@ -868,6 +868,11 @@ public partial class MainWindow : Window
SetUpdateStatus("Updater.Status.Installing"); SetUpdateStatus("Updater.Status.Installing");
Close(); Close();
} }
catch (InvalidDataException)
{
SetUpdateStatus("Updater.Status.ChecksumFailed");
UpdateButton.IsEnabled = true;
}
catch catch
{ {
SetUpdateStatus("Updater.Status.Failed"); SetUpdateStatus("Updater.Status.Failed");
+206 -37
View File
@@ -6,7 +6,10 @@ using System.Formats.Tar;
using System.IO.Compression; using System.IO.Compression;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using System.Text.RegularExpressions;
using System.Reflection;
namespace SharpEmu.GUI; namespace SharpEmu.GUI;
@@ -18,7 +21,7 @@ public static class Updater
private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10);
private static readonly HttpClient Http = CreateHttpClient(); private static readonly HttpClient Http = CreateHttpClient();
public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size); public sealed record UpdateInfo(string Sha, string Name, string DownloadUrl, long Size, string Sha256, string TagName);
public static async Task<UpdateInfo?> CheckAsync(string? currentSha, CancellationToken cancellationToken = default) public static async Task<UpdateInfo?> CheckAsync(string? currentSha, CancellationToken cancellationToken = default)
{ {
@@ -28,11 +31,31 @@ public static class Updater
using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token); using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
return ParseRelease( var update = ParseRelease(
await response.Content.ReadAsStringAsync(timeout.Token), await response.Content.ReadAsStringAsync(timeout.Token),
currentSha, null,
platform.Rid, platform.Rid,
platform.Extension); platform.Extension);
var currentVersion = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (update is null || currentSha is null ||
string.Equals(update.Sha, currentSha, StringComparison.OrdinalIgnoreCase))
{
return null;
}
if (currentVersion is not null &&
TryParseVersion(currentVersion, out var installed) &&
TryParseVersion(update.TagName, out var available) &&
available.CompareTo(installed) <= 0)
{
return null;
}
var comparison = await CompareCommitsAsync(currentSha, update.Sha, timeout.Token);
return comparison.Status == "ahead" && comparison.ReleaseDate > comparison.CurrentDate
? update
: null;
} }
public static async Task DownloadAndRestartAsync( public static async Task DownloadAndRestartAsync(
@@ -47,42 +70,63 @@ public static class Updater
Directory.Delete(root, recursive: true); Directory.Delete(root, recursive: true);
} }
Directory.CreateDirectory(root); var launched = false;
var archive = Path.Combine(root, update.Name); try
using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
{ {
response.EnsureSuccessStatusCode(); Directory.CreateDirectory(root);
await using var input = await response.Content.ReadAsStreamAsync(cancellationToken); var archive = Path.Combine(root, update.Name);
await using var output = File.Create(archive); using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken))
var buffer = new byte[81920];
long written = 0;
int read;
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
{ {
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); response.EnsureSuccessStatusCode();
written += read; await using var input = await response.Content.ReadAsStreamAsync(cancellationToken);
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size)); await using var output = File.Create(archive);
var buffer = new byte[81920];
long written = 0;
int read;
while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0)
{
await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken);
written += read;
progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size));
}
if (written != update.Size)
{
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}.");
}
} }
if (written != update.Size) await using (var archiveStream = File.OpenRead(archive))
{ {
throw new InvalidDataException($"Downloaded {written} bytes; expected {update.Size}."); var actualSha256 = Convert.ToHexString(await SHA256.HashDataAsync(archiveStream, cancellationToken));
if (!string.Equals(actualSha256, update.Sha256, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidDataException($"SHA-256 mismatch; expected {update.Sha256}, got {actualSha256}.");
}
}
var platform = CurrentPlatform();
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
var start = new ProcessStartInfo(stagedExe)
{
UseShellExecute = false,
WorkingDirectory = payload,
};
start.ArgumentList.Add(ApplyArgument);
start.ArgumentList.Add(Environment.ProcessId.ToString());
start.ArgumentList.Add(AppContext.BaseDirectory);
using var helper = Process.Start(start)
?? throw new InvalidOperationException("The update installer could not be started.");
launched = true;
}
finally
{
if (!launched)
{
TryDeleteDirectory(root);
} }
} }
var platform = CurrentPlatform();
var stagedExe = ExtractArchive(archive, payload, platform.Extension, platform.ExecutableName);
var start = new ProcessStartInfo(stagedExe)
{
UseShellExecute = false,
WorkingDirectory = payload,
};
start.ArgumentList.Add(ApplyArgument);
start.ArgumentList.Add(Environment.ProcessId.ToString());
start.ArgumentList.Add(AppContext.BaseDirectory);
using var helper = Process.Start(start)
?? throw new InvalidOperationException("The update installer could not be started.");
} }
/// <summary>Runs from the downloaded executable after the old GUI exits.</summary> /// <summary>Runs from the downloaded executable after the old GUI exits.</summary>
@@ -94,6 +138,8 @@ public static class Updater
return false; return false;
} }
var backup = Path.Combine(Path.GetTempPath(), $"SharpEmu.UpdateBackup-{Environment.ProcessId}");
var changed = new List<(string Destination, string? Backup)>();
try try
{ {
if (int.TryParse(args[1], out var oldPid)) if (int.TryParse(args[1], out var oldPid))
@@ -113,6 +159,7 @@ public static class Updater
var source = AppContext.BaseDirectory; var source = AppContext.BaseDirectory;
var target = Path.GetFullPath(args[2]); var target = Path.GetFullPath(args[2]);
Directory.CreateDirectory(backup);
foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories))
{ {
var relative = Path.GetRelativePath(source, file); var relative = Path.GetRelativePath(source, file);
@@ -126,6 +173,14 @@ public static class Updater
var destination = Path.Combine(target, relative); var destination = Path.Combine(target, relative);
Directory.CreateDirectory(Path.GetDirectoryName(destination)!); Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
string? backupFile = null;
if (File.Exists(destination))
{
backupFile = Path.Combine(backup, relative);
Directory.CreateDirectory(Path.GetDirectoryName(backupFile)!);
File.Copy(destination, backupFile, overwrite: true);
}
changed.Add((destination, backupFile));
File.Copy(file, destination, overwrite: true); File.Copy(file, destination, overwrite: true);
if (!OperatingSystem.IsWindows()) if (!OperatingSystem.IsWindows())
{ {
@@ -139,10 +194,31 @@ public static class Updater
UseShellExecute = false, UseShellExecute = false,
WorkingDirectory = target, WorkingDirectory = target,
}) ?? throw new InvalidOperationException("The updated SharpEmu could not be started."); }) ?? throw new InvalidOperationException("The updated SharpEmu could not be started.");
TryDeleteDirectory(backup);
} }
catch (Exception ex) catch (Exception ex)
{ {
exitCode = 1; exitCode = 1;
foreach (var (destination, backupFile) in changed.AsEnumerable().Reverse())
{
try
{
if (backupFile is null)
{
File.Delete(destination);
}
else if (File.Exists(backupFile))
{
Directory.CreateDirectory(Path.GetDirectoryName(destination)!);
File.Copy(backupFile, destination, overwrite: true);
}
}
catch
{
// Best-effort rollback; the original error is more useful to the user.
}
}
TryDeleteDirectory(backup);
try try
{ {
File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString()); File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString());
@@ -163,11 +239,12 @@ public static class Updater
string extension) string extension)
{ {
using var document = JsonDocument.Parse(json); using var document = JsonDocument.Parse(json);
var releaseSha = ExtractReleaseSha(document.RootElement);
var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>(); var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>();
foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray()) foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray())
{ {
var name = asset.GetProperty("name").GetString() ?? ""; var name = asset.GetProperty("name").GetString() ?? "";
var marker = $"-{rid}-"; var marker = $"-{rid}";
var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase); var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) || if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) ||
markerIndex < 0) markerIndex < 0)
@@ -175,8 +252,21 @@ public static class Updater
continue; continue;
} }
var sha = name[(markerIndex + marker.Length)..^extension.Length]; var suffix = name[(markerIndex + marker.Length)..^extension.Length].TrimStart('-');
if (sha.Length < 7 || !sha.All(Uri.IsHexDigit)) var assetSha = suffix.Length >= 7 && suffix.All(Uri.IsHexDigit)
? suffix
: releaseSha;
if (assetSha is null ||
!asset.TryGetProperty("digest", out var digestProperty) ||
digestProperty.ValueKind != JsonValueKind.String)
{
continue;
}
var digest = digestProperty.GetString() ?? "";
if (!digest.StartsWith("sha256:", StringComparison.OrdinalIgnoreCase) ||
digest.Length != "sha256:".Length + 64 ||
!digest["sha256:".Length..].All(Uri.IsHexDigit))
{ {
continue; continue;
} }
@@ -184,10 +274,12 @@ public static class Updater
candidates.Add(( candidates.Add((
asset.GetProperty("created_at").GetDateTimeOffset(), asset.GetProperty("created_at").GetDateTimeOffset(),
new UpdateInfo( new UpdateInfo(
sha, assetSha,
name, name,
asset.GetProperty("browser_download_url").GetString()!, asset.GetProperty("browser_download_url").GetString()!,
asset.GetProperty("size").GetInt64()))); asset.GetProperty("size").GetInt64(),
digest["sha256:".Length..],
document.RootElement.GetProperty("tag_name").GetString() ?? "")));
} }
var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update; var latest = candidates.OrderByDescending(candidate => candidate.Created).FirstOrDefault().Update;
@@ -196,6 +288,73 @@ public static class Updater
: latest; : latest;
} }
private static async Task<CommitComparison> CompareCommitsAsync(
string currentSha,
string releaseSha,
CancellationToken cancellationToken)
{
var url = $"https://api.github.com/repos/sharpemu/sharpemu/compare/{currentSha}...{releaseSha}";
using var response = await Http.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var document = JsonDocument.Parse(await response.Content.ReadAsStringAsync(cancellationToken));
var root = document.RootElement;
var currentDate = root.GetProperty("base_commit").GetProperty("commit").GetProperty("committer").GetProperty("date").GetDateTimeOffset();
var releaseDate = currentDate;
if (root.TryGetProperty("commits", out var commits) && commits.GetArrayLength() > 0)
{
releaseDate = commits[commits.GetArrayLength() - 1]
.GetProperty("commit").GetProperty("committer").GetProperty("date").GetDateTimeOffset();
}
return new CommitComparison(root.GetProperty("status").GetString() ?? "", currentDate, releaseDate);
}
private static string? ExtractReleaseSha(JsonElement release)
{
if (!release.TryGetProperty("body", out var bodyProperty) ||
bodyProperty.ValueKind != JsonValueKind.String)
{
return null;
}
var body = bodyProperty.GetString();
var match = Regex.Match(
body ?? "",
@"\bcommit\s+([0-9a-f]{7,40})\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
if (!match.Success)
{
return null;
}
var sha = match.Groups[1].Value;
return sha.Length > 7 ? sha[..7] : sha;
}
private static bool TryParseVersion(string value, out ReleaseVersion version)
{
var match = Regex.Match(value.TrimStart('v'), @"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?");
if (!match.Success || !int.TryParse(match.Groups[1].Value, out var major) ||
!int.TryParse(match.Groups[2].Value, out var minor) ||
!int.TryParse(match.Groups[3].Value, out var patch))
{
version = default;
return false;
}
version = new ReleaseVersion(major, minor, patch, match.Groups[4].Value);
return true;
}
private static void TryDeleteDirectory(string path)
{
try
{
if (Directory.Exists(path)) Directory.Delete(path, recursive: true);
}
catch { }
}
private static string ExtractArchive( private static string ExtractArchive(
string archive, string archive,
string payload, string payload,
@@ -250,4 +409,14 @@ public static class Updater
} }
private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName); private sealed record PlatformInfo(string Rid, string Extension, string ExecutableName);
private sealed record CommitComparison(string Status, DateTimeOffset CurrentDate, DateTimeOffset ReleaseDate);
private readonly record struct ReleaseVersion(int Major, int Minor, int Patch, string PreRelease) : IComparable<ReleaseVersion>
{
public int CompareTo(ReleaseVersion other) =>
(Major, Minor, Patch) != (other.Major, other.Minor, other.Patch)
? (Major, Minor, Patch).CompareTo((other.Major, other.Minor, other.Patch))
: string.IsNullOrEmpty(PreRelease) == string.IsNullOrEmpty(other.PreRelease)
? string.CompareOrdinal(PreRelease, other.PreRelease)
: string.IsNullOrEmpty(PreRelease) ? 1 : -1;
}
} }