launcher: add streaming hasher and range-resume blob downloader
This commit is contained in:
135
src/Metin2Launcher/Transfer/BlobDownloader.cs
Normal file
135
src/Metin2Launcher/Transfer/BlobDownloader.cs
Normal file
@@ -0,0 +1,135 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Metin2Launcher.Logging;
|
||||
|
||||
namespace Metin2Launcher.Transfer;
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a content-addressed blob over HTTP with:
|
||||
/// - HTTP Range resume (picks up where the staging file left off)
|
||||
/// - sha256 verification after each full download
|
||||
/// - Retry with exponential backoff (capped at MaxRetries)
|
||||
///
|
||||
/// Blob URL layout: {baseUrl}/{hash[0:2]}/{hash}
|
||||
/// </summary>
|
||||
public sealed class BlobDownloader
|
||||
{
|
||||
private readonly HttpClient _http;
|
||||
private readonly string _baseUrl;
|
||||
private readonly int _maxRetries;
|
||||
|
||||
public BlobDownloader(HttpClient http, string baseUrl, int maxRetries)
|
||||
{
|
||||
_http = http;
|
||||
_baseUrl = baseUrl.TrimEnd('/');
|
||||
_maxRetries = maxRetries;
|
||||
}
|
||||
|
||||
public static string BlobUrl(string baseUrl, string sha256)
|
||||
{
|
||||
if (sha256.Length < 2)
|
||||
throw new ArgumentException("sha256 too short", nameof(sha256));
|
||||
var prefix = sha256.Substring(0, 2);
|
||||
return $"{baseUrl.TrimEnd('/')}/{prefix}/{sha256}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Downloads a blob into <paramref name="destinationPath"/>, resuming if the file already exists.
|
||||
/// Throws <see cref="BlobDownloadException"/> after all retries fail or on final hash mismatch.
|
||||
/// </summary>
|
||||
public async Task DownloadAsync(
|
||||
string sha256,
|
||||
long expectedSize,
|
||||
string destinationPath,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var url = BlobUrl(_baseUrl, sha256);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!);
|
||||
|
||||
Exception? lastError = null;
|
||||
for (int attempt = 1; attempt <= _maxRetries; attempt++)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
await DownloadOnceAsync(url, expectedSize, destinationPath, ct).ConfigureAwait(false);
|
||||
var actual = FileHasher.HashFileOrNull(destinationPath);
|
||||
if (actual == sha256)
|
||||
return;
|
||||
|
||||
Log.Warn($"blob hash mismatch for {sha256} (got {actual}), attempt {attempt}");
|
||||
TryDelete(destinationPath);
|
||||
lastError = new BlobDownloadException($"hash mismatch for {sha256}: got {actual}");
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warn($"blob download attempt {attempt} failed for {sha256}: {ex.Message}");
|
||||
lastError = ex;
|
||||
}
|
||||
|
||||
if (attempt < _maxRetries)
|
||||
{
|
||||
var delay = TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1));
|
||||
try { await Task.Delay(delay, ct).ConfigureAwait(false); }
|
||||
catch (OperationCanceledException) { throw; }
|
||||
}
|
||||
}
|
||||
|
||||
throw new BlobDownloadException(
|
||||
$"blob {sha256} failed after {_maxRetries} attempts", lastError);
|
||||
}
|
||||
|
||||
private async Task DownloadOnceAsync(string url, long expectedSize, string destinationPath, CancellationToken ct)
|
||||
{
|
||||
long have = File.Exists(destinationPath) ? new FileInfo(destinationPath).Length : 0;
|
||||
if (have > expectedSize)
|
||||
{
|
||||
// Local file is longer than the target — nothing to resume, start over.
|
||||
TryDelete(destinationPath);
|
||||
have = 0;
|
||||
}
|
||||
if (have == expectedSize)
|
||||
{
|
||||
// Full size already on disk — the caller's hash check decides validity.
|
||||
return;
|
||||
}
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
if (have > 0)
|
||||
req.Headers.Range = new RangeHeaderValue(have, null);
|
||||
|
||||
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (have > 0 && resp.StatusCode != HttpStatusCode.PartialContent)
|
||||
{
|
||||
// Server ignored our Range header — restart from scratch.
|
||||
TryDelete(destinationPath);
|
||||
have = 0;
|
||||
}
|
||||
else
|
||||
{
|
||||
resp.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
var mode = have > 0 ? FileMode.Append : FileMode.Create;
|
||||
await using var fs = new FileStream(destinationPath, mode, FileAccess.Write, FileShare.None, 1024 * 1024);
|
||||
await using var net = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
|
||||
await net.CopyToAsync(fs, 1024 * 1024, ct).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { }
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class BlobDownloadException : Exception
|
||||
{
|
||||
public BlobDownloadException(string message) : base(message) { }
|
||||
public BlobDownloadException(string message, Exception? inner) : base(message, inner) { }
|
||||
}
|
||||
36
src/Metin2Launcher/Transfer/FileHasher.cs
Normal file
36
src/Metin2Launcher/Transfer/FileHasher.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace Metin2Launcher.Transfer;
|
||||
|
||||
/// <summary>Streaming sha256 helpers.</summary>
|
||||
public static class FileHasher
|
||||
{
|
||||
private const int BufferSize = 1024 * 1024; // 1 MiB
|
||||
|
||||
/// <summary>Computes the sha256 of a file as lowercase hex. Returns null if the file doesn't exist.</summary>
|
||||
public static string? HashFileOrNull(string path)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
return null;
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, BufferSize, FileOptions.SequentialScan);
|
||||
return HashStream(fs);
|
||||
}
|
||||
|
||||
/// <summary>Computes the sha256 of a stream as lowercase hex.</summary>
|
||||
public static string HashStream(Stream stream)
|
||||
{
|
||||
using var sha = SHA256.Create();
|
||||
var buf = new byte[BufferSize];
|
||||
int n;
|
||||
while ((n = stream.Read(buf, 0, buf.Length)) > 0)
|
||||
{
|
||||
sha.TransformBlock(buf, 0, n, null, 0);
|
||||
}
|
||||
sha.TransformFinalBlock(Array.Empty<byte>(), 0, 0);
|
||||
return Convert.ToHexString(sha.Hash!).ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>Computes the sha256 of a byte span as lowercase hex.</summary>
|
||||
public static string HashBytes(ReadOnlySpan<byte> data)
|
||||
=> Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
}
|
||||
189
tests/Metin2Launcher.Tests/BlobDownloaderTests.cs
Normal file
189
tests/Metin2Launcher.Tests/BlobDownloaderTests.cs
Normal file
@@ -0,0 +1,189 @@
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using Metin2Launcher.Transfer;
|
||||
using Xunit;
|
||||
|
||||
namespace Metin2Launcher.Tests;
|
||||
|
||||
public class BlobDownloaderTests : IDisposable
|
||||
{
|
||||
private readonly string _tmp;
|
||||
|
||||
public BlobDownloaderTests()
|
||||
{
|
||||
_tmp = Path.Combine(Path.GetTempPath(), "metin-blob-" + Guid.NewGuid());
|
||||
Directory.CreateDirectory(_tmp);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
try { Directory.Delete(_tmp, recursive: true); } catch { }
|
||||
}
|
||||
|
||||
/// <summary>Minimal HttpListener-based test server. Serves blobs at /{hash[0:2]}/{hash}.</summary>
|
||||
private sealed class BlobServer : IDisposable
|
||||
{
|
||||
private readonly HttpListener _listener;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private readonly Task _loop;
|
||||
|
||||
public string BaseUrl { get; }
|
||||
public Func<HttpListenerRequest, HttpListenerResponse, Task>? Handler { get; set; }
|
||||
|
||||
public BlobServer()
|
||||
{
|
||||
var port = GetFreeTcpPort();
|
||||
BaseUrl = $"http://127.0.0.1:{port}";
|
||||
_listener = new HttpListener();
|
||||
_listener.Prefixes.Add(BaseUrl + "/");
|
||||
_listener.Start();
|
||||
_loop = Task.Run(RunAsync);
|
||||
}
|
||||
|
||||
private async Task RunAsync()
|
||||
{
|
||||
while (!_cts.IsCancellationRequested)
|
||||
{
|
||||
HttpListenerContext ctx;
|
||||
try { ctx = await _listener.GetContextAsync().ConfigureAwait(false); }
|
||||
catch { return; }
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Handler is not null)
|
||||
await Handler(ctx.Request, ctx.Response).ConfigureAwait(false);
|
||||
}
|
||||
catch { }
|
||||
finally
|
||||
{
|
||||
try { ctx.Response.Close(); } catch { }
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_cts.Cancel();
|
||||
try { _listener.Stop(); } catch { }
|
||||
try { _loop.Wait(500); } catch { }
|
||||
try { _listener.Close(); } catch { }
|
||||
}
|
||||
|
||||
private static int GetFreeTcpPort()
|
||||
{
|
||||
var l = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
|
||||
l.Start();
|
||||
var port = ((System.Net.IPEndPoint)l.LocalEndpoint).Port;
|
||||
l.Stop();
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Sha256Hex(byte[] data)
|
||||
=> Convert.ToHexString(SHA256.HashData(data)).ToLowerInvariant();
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_full_blob_and_matches_hash()
|
||||
{
|
||||
var payload = new byte[16 * 1024];
|
||||
new Random(1).NextBytes(payload);
|
||||
var hash = Sha256Hex(payload);
|
||||
|
||||
using var server = new BlobServer();
|
||||
server.Handler = async (req, resp) =>
|
||||
{
|
||||
resp.StatusCode = 200;
|
||||
resp.ContentLength64 = payload.Length;
|
||||
await resp.OutputStream.WriteAsync(payload);
|
||||
};
|
||||
|
||||
using var http = new HttpClient();
|
||||
var dl = new BlobDownloader(http, server.BaseUrl, maxRetries: 3);
|
||||
var dest = Path.Combine(_tmp, "full.bin");
|
||||
await dl.DownloadAsync(hash, payload.Length, dest, CancellationToken.None);
|
||||
|
||||
Assert.Equal(hash, FileHasher.HashFileOrNull(dest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Resumes_partial_download_via_range()
|
||||
{
|
||||
var payload = new byte[8 * 1024];
|
||||
new Random(2).NextBytes(payload);
|
||||
var hash = Sha256Hex(payload);
|
||||
|
||||
var rangeSeen = false;
|
||||
using var server = new BlobServer();
|
||||
server.Handler = async (req, resp) =>
|
||||
{
|
||||
var range = req.Headers["Range"];
|
||||
if (!string.IsNullOrEmpty(range))
|
||||
{
|
||||
rangeSeen = true;
|
||||
// parse "bytes=N-"
|
||||
var from = long.Parse(range.Replace("bytes=", "").TrimEnd('-'));
|
||||
resp.StatusCode = 206;
|
||||
resp.ContentLength64 = payload.Length - from;
|
||||
resp.AddHeader("Content-Range", $"bytes {from}-{payload.Length - 1}/{payload.Length}");
|
||||
await resp.OutputStream.WriteAsync(payload.AsMemory((int)from));
|
||||
}
|
||||
else
|
||||
{
|
||||
resp.StatusCode = 200;
|
||||
resp.ContentLength64 = payload.Length;
|
||||
await resp.OutputStream.WriteAsync(payload);
|
||||
}
|
||||
};
|
||||
|
||||
var dest = Path.Combine(_tmp, "resume.bin");
|
||||
// Pre-seed with the first half of the payload to simulate a prior interrupted download.
|
||||
File.WriteAllBytes(dest, payload.Take(payload.Length / 2).ToArray());
|
||||
|
||||
using var http = new HttpClient();
|
||||
var dl = new BlobDownloader(http, server.BaseUrl, maxRetries: 3);
|
||||
await dl.DownloadAsync(hash, payload.Length, dest, CancellationToken.None);
|
||||
|
||||
Assert.True(rangeSeen, "server should have seen a Range header");
|
||||
Assert.Equal(hash, FileHasher.HashFileOrNull(dest));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Retries_and_fails_on_persistent_hash_mismatch()
|
||||
{
|
||||
var realPayload = new byte[1024];
|
||||
new Random(3).NextBytes(realPayload);
|
||||
var expectedHash = Sha256Hex(realPayload);
|
||||
|
||||
var corrupted = (byte[])realPayload.Clone();
|
||||
corrupted[0] ^= 0xFF;
|
||||
|
||||
var attempts = 0;
|
||||
using var server = new BlobServer();
|
||||
server.Handler = async (req, resp) =>
|
||||
{
|
||||
Interlocked.Increment(ref attempts);
|
||||
resp.StatusCode = 200;
|
||||
resp.ContentLength64 = corrupted.Length;
|
||||
await resp.OutputStream.WriteAsync(corrupted);
|
||||
};
|
||||
|
||||
using var http = new HttpClient();
|
||||
var dl = new BlobDownloader(http, server.BaseUrl, maxRetries: 3);
|
||||
var dest = Path.Combine(_tmp, "bad.bin");
|
||||
|
||||
await Assert.ThrowsAsync<BlobDownloadException>(() =>
|
||||
dl.DownloadAsync(expectedHash, corrupted.Length, dest, CancellationToken.None));
|
||||
|
||||
Assert.Equal(3, attempts);
|
||||
Assert.False(File.Exists(dest), "corrupt staging file should be deleted");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BlobUrl_is_content_addressed_with_two_char_prefix()
|
||||
{
|
||||
var url = BlobDownloader.BlobUrl("https://example.com/files", "abcdef0123456789");
|
||||
Assert.Equal("https://example.com/files/ab/abcdef0123456789", url);
|
||||
}
|
||||
}
|
||||
55
tests/Metin2Launcher.Tests/FileHasherTests.cs
Normal file
55
tests/Metin2Launcher.Tests/FileHasherTests.cs
Normal file
@@ -0,0 +1,55 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Metin2Launcher.Transfer;
|
||||
using Xunit;
|
||||
|
||||
namespace Metin2Launcher.Tests;
|
||||
|
||||
public class FileHasherTests
|
||||
{
|
||||
[Fact]
|
||||
public void HashBytes_matches_known_good_value_for_empty_input()
|
||||
{
|
||||
// Known sha256 of the empty string.
|
||||
const string empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
|
||||
Assert.Equal(empty, FileHasher.HashBytes(ReadOnlySpan<byte>.Empty));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashBytes_matches_known_good_value_for_abc()
|
||||
{
|
||||
// Standard NIST test vector: sha256("abc")
|
||||
const string expected = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
|
||||
Assert.Equal(expected, FileHasher.HashBytes(Encoding.ASCII.GetBytes("abc")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashFileOrNull_returns_null_for_missing_file()
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), "metin-test-missing-" + Guid.NewGuid());
|
||||
Assert.Null(FileHasher.HashFileOrNull(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HashStream_matches_SHA256_on_random_10MB_input()
|
||||
{
|
||||
var rand = new Random(12345);
|
||||
var buf = new byte[10 * 1024 * 1024];
|
||||
rand.NextBytes(buf);
|
||||
|
||||
var expected = Convert.ToHexString(SHA256.HashData(buf)).ToLowerInvariant();
|
||||
var tmp = Path.GetTempFileName();
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(tmp, buf);
|
||||
Assert.Equal(expected, FileHasher.HashFileOrNull(tmp));
|
||||
|
||||
using var ms = new MemoryStream(buf);
|
||||
Assert.Equal(expected, FileHasher.HashStream(ms));
|
||||
}
|
||||
finally
|
||||
{
|
||||
File.Delete(tmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user