launcher: add streaming hasher and range-resume blob downloader

This commit is contained in:
Jan Nedbal
2026-04-14 11:12:35 +02:00
parent 18271a71db
commit 80e1450df9
4 changed files with 415 additions and 0 deletions

View 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) { }
}

View 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();
}

View 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);
}
}

View 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);
}
}
}