feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
200
src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs
Normal file
200
src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes)
|
||||
{
|
||||
public double Percent => TotalBytes <= 0
|
||||
? 0
|
||||
: Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100);
|
||||
}
|
||||
|
||||
public sealed record VerifiedArtifactDownload(
|
||||
string FilePath,
|
||||
long Size,
|
||||
string Sha256);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a bounded release asset directly to a caller-owned staging path,
|
||||
/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is
|
||||
/// deleted before the call returns.
|
||||
/// </summary>
|
||||
public sealed class VerifiedArtifactDownloader
|
||||
{
|
||||
private const int BufferSize = 128 * 1024;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public VerifiedArtifactDownloader(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task<VerifiedArtifactDownload> DownloadAsync(
|
||||
ReleaseArtifact artifact,
|
||||
string destinationPath,
|
||||
IProgress<ArtifactDownloadProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
||||
ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact");
|
||||
if (artifact.Size <= 0
|
||||
|| artifact.Size > ReleaseManifestClient.MaximumArtifactBytes
|
||||
|| !ReleaseManifestClient.IsSha256(artifact.Sha256))
|
||||
{
|
||||
throw new LauncherUpdateException("The requested artifact metadata is invalid.");
|
||||
}
|
||||
|
||||
string fullPath = Path.GetFullPath(destinationPath);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(fullPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The artifact staging path has no parent directory."));
|
||||
|
||||
bool ownsDestination = false;
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
artifact.Url,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
ReleaseManifestClient.RequireSecureOrLoopback(
|
||||
response.RequestMessage?.RequestUri ?? artifact.Url,
|
||||
"artifact redirect");
|
||||
if (response.Content.Headers.ContentLength is long contentLength
|
||||
&& contentLength != artifact.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Artifact size header mismatch: expected {artifact.Size}, "
|
||||
+ $"received {contentLength}.");
|
||||
}
|
||||
|
||||
if (response.Content.Headers.ContentEncoding.Count != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Release artifact content encoding is not allowed.");
|
||||
}
|
||||
|
||||
await using Stream input = await response.Content
|
||||
.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var output = new FileStream(
|
||||
fullPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
FileOptions.Asynchronous
|
||||
| FileOptions.SequentialScan
|
||||
| FileOptions.WriteThrough);
|
||||
ownsDestination = true;
|
||||
using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
long received = 0;
|
||||
try
|
||||
{
|
||||
progress?.Report(new ArtifactDownloadProgress(0, artifact.Size));
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(
|
||||
buffer.AsMemory(0, BufferSize),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
received = checked(received + read);
|
||||
if (received > artifact.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Artifact exceeded its declared size of {artifact.Size} bytes.");
|
||||
}
|
||||
|
||||
hash.AppendData(buffer, 0, read);
|
||||
await output.WriteAsync(
|
||||
buffer.AsMemory(0, read),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
progress?.Report(new ArtifactDownloadProgress(received, artifact.Size));
|
||||
}
|
||||
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
output.Flush(flushToDisk: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
|
||||
if (received != artifact.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Artifact ended at {received} bytes; expected {artifact.Size}.");
|
||||
}
|
||||
|
||||
string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset());
|
||||
if (!string.Equals(
|
||||
actualSha256,
|
||||
artifact.Sha256,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Artifact SHA-256 does not match the release manifest.");
|
||||
}
|
||||
|
||||
return new VerifiedArtifactDownload(fullPath, received, actualSha256);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (ownsDestination)
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
if (ownsDestination)
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or CryptographicException)
|
||||
{
|
||||
if (ownsDestination)
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
}
|
||||
|
||||
throw new LauncherUpdateException(
|
||||
$"The release artifact could not be downloaded: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The exact random staging name is reclaimed by startup recovery.
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue