mirror of
https://github.com/par274/sharpemu.git
synced 2026-07-22 10:56:20 +08:00
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:
committed by
GitHub
parent
b3e3fe5ea8
commit
e6be48a390
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user