From e6be48a390c07f4a52b54fa125ccac24d95e2cf3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmed=20Sinan=20K=C3=B6mek?= <32844718+mskomek@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:41:59 +0300 Subject: [PATCH] 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 --- src/SharpEmu.GUI/Languages/en.json | 1 + src/SharpEmu.GUI/Languages/tr.json | 1 + src/SharpEmu.GUI/MainWindow.axaml.cs | 5 + src/SharpEmu.GUI/Updater.cs | 243 +++++++++++++++++++++++---- 4 files changed, 213 insertions(+), 37 deletions(-) diff --git a/src/SharpEmu.GUI/Languages/en.json b/src/SharpEmu.GUI/Languages/en.json index bf85c064..9d4e7ab4 100644 --- a/src/SharpEmu.GUI/Languages/en.json +++ b/src/SharpEmu.GUI/Languages/en.json @@ -169,5 +169,6 @@ "Updater.Status.Installing": "Installing update…", "Updater.Status.Timeout": "Update check timed out after 10 seconds.", "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." } diff --git a/src/SharpEmu.GUI/Languages/tr.json b/src/SharpEmu.GUI/Languages/tr.json index 957fa806..7c074f9f 100644 --- a/src/SharpEmu.GUI/Languages/tr.json +++ b/src/SharpEmu.GUI/Languages/tr.json @@ -140,5 +140,6 @@ "Updater.Status.Installing": "Güncelleme kuruluyor…", "Updater.Status.Timeout": "Güncelleme denetimi 10 saniye sonra zaman aşımına uğradı.", "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." } diff --git a/src/SharpEmu.GUI/MainWindow.axaml.cs b/src/SharpEmu.GUI/MainWindow.axaml.cs index 1a7466a7..fb5f20bd 100644 --- a/src/SharpEmu.GUI/MainWindow.axaml.cs +++ b/src/SharpEmu.GUI/MainWindow.axaml.cs @@ -868,6 +868,11 @@ public partial class MainWindow : Window SetUpdateStatus("Updater.Status.Installing"); Close(); } + catch (InvalidDataException) + { + SetUpdateStatus("Updater.Status.ChecksumFailed"); + UpdateButton.IsEnabled = true; + } catch { SetUpdateStatus("Updater.Status.Failed"); diff --git a/src/SharpEmu.GUI/Updater.cs b/src/SharpEmu.GUI/Updater.cs index 57dc1333..0cb70064 100644 --- a/src/SharpEmu.GUI/Updater.cs +++ b/src/SharpEmu.GUI/Updater.cs @@ -6,7 +6,10 @@ using System.Formats.Tar; using System.IO.Compression; using System.Net.Http.Headers; using System.Runtime.InteropServices; +using System.Security.Cryptography; using System.Text.Json; +using System.Text.RegularExpressions; +using System.Reflection; namespace SharpEmu.GUI; @@ -18,7 +21,7 @@ public static class Updater private static readonly TimeSpan CheckTimeout = TimeSpan.FromSeconds(10); 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 CheckAsync(string? currentSha, CancellationToken cancellationToken = default) { @@ -28,11 +31,31 @@ public static class Updater using var response = await Http.GetAsync(LatestReleaseUrl, timeout.Token); response.EnsureSuccessStatusCode(); - return ParseRelease( + var update = ParseRelease( await response.Content.ReadAsStringAsync(timeout.Token), - currentSha, + null, platform.Rid, platform.Extension); + var currentVersion = Assembly.GetExecutingAssembly() + .GetCustomAttribute()?.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( @@ -47,42 +70,63 @@ public static class Updater Directory.Delete(root, recursive: true); } - Directory.CreateDirectory(root); - var archive = Path.Combine(root, update.Name); - using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + var launched = false; + try { - response.EnsureSuccessStatusCode(); - await using var input = await response.Content.ReadAsStreamAsync(cancellationToken); - 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) + Directory.CreateDirectory(root); + var archive = Path.Combine(root, update.Name); + using (var response = await Http.GetAsync(update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) { - await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); - written += read; - progress?.Report(update.Size == 0 ? 0 : (int)(written * 100 / update.Size)); + response.EnsureSuccessStatusCode(); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken); + 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."); } /// Runs from the downloaded executable after the old GUI exits. @@ -94,6 +138,8 @@ public static class Updater return false; } + var backup = Path.Combine(Path.GetTempPath(), $"SharpEmu.UpdateBackup-{Environment.ProcessId}"); + var changed = new List<(string Destination, string? Backup)>(); try { if (int.TryParse(args[1], out var oldPid)) @@ -113,6 +159,7 @@ public static class Updater var source = AppContext.BaseDirectory; var target = Path.GetFullPath(args[2]); + Directory.CreateDirectory(backup); foreach (var file in Directory.EnumerateFiles(source, "*", SearchOption.AllDirectories)) { var relative = Path.GetRelativePath(source, file); @@ -126,6 +173,14 @@ public static class Updater var destination = Path.Combine(target, relative); 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); if (!OperatingSystem.IsWindows()) { @@ -139,10 +194,31 @@ public static class Updater UseShellExecute = false, WorkingDirectory = target, }) ?? throw new InvalidOperationException("The updated SharpEmu could not be started."); + TryDeleteDirectory(backup); } catch (Exception ex) { 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 { File.WriteAllText(Path.Combine(args[2], "update-error.log"), ex.ToString()); @@ -163,11 +239,12 @@ public static class Updater string extension) { using var document = JsonDocument.Parse(json); + var releaseSha = ExtractReleaseSha(document.RootElement); var candidates = new List<(DateTimeOffset Created, UpdateInfo Update)>(); foreach (var asset in document.RootElement.GetProperty("assets").EnumerateArray()) { var name = asset.GetProperty("name").GetString() ?? ""; - var marker = $"-{rid}-"; + var marker = $"-{rid}"; var markerIndex = name.LastIndexOf(marker, StringComparison.OrdinalIgnoreCase); if (!name.EndsWith(extension, StringComparison.OrdinalIgnoreCase) || markerIndex < 0) @@ -175,8 +252,21 @@ public static class Updater continue; } - var sha = name[(markerIndex + marker.Length)..^extension.Length]; - if (sha.Length < 7 || !sha.All(Uri.IsHexDigit)) + var suffix = name[(markerIndex + marker.Length)..^extension.Length].TrimStart('-'); + 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; } @@ -184,10 +274,12 @@ public static class Updater candidates.Add(( asset.GetProperty("created_at").GetDateTimeOffset(), new UpdateInfo( - sha, + assetSha, name, 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; @@ -196,6 +288,73 @@ public static class Updater : latest; } + private static async Task 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( string archive, string payload, @@ -250,4 +409,14 @@ public static class Updater } 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 + { + 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; + } }