feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
|
|
@ -0,0 +1,219 @@
|
|||
using System.Text;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
public sealed class LauncherVersionTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("1.0.0-alpha", "1.0.0-alpha.1")]
|
||||
[InlineData("1.0.0-alpha.1", "1.0.0-alpha.beta")]
|
||||
[InlineData("1.0.0-beta.11", "1.0.0-rc.1")]
|
||||
[InlineData("1.0.0-rc.1", "1.0.0")]
|
||||
[InlineData("1.9.999999999999999999999", "1.10.0")]
|
||||
[InlineData("999999999999999999999.0.0", "1000000000000000000000.0.0")]
|
||||
public void StrictSemVerOrdersWithoutNumericOverflow(string lower, string higher)
|
||||
{
|
||||
LauncherVersion left = LauncherVersion.Parse(lower);
|
||||
LauncherVersion right = LauncherVersion.Parse(higher);
|
||||
|
||||
Assert.True(left < right);
|
||||
Assert.True(right > left);
|
||||
Assert.Equal(0, LauncherVersion.Parse(higher + "+build.7").CompareTo(right));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" 1.0.0")]
|
||||
[InlineData("1.0")]
|
||||
[InlineData("01.0.0")]
|
||||
[InlineData("1.0.0-01")]
|
||||
[InlineData("1.0.0-")]
|
||||
[InlineData("1.0.0+")]
|
||||
[InlineData("v1.0.0")]
|
||||
public void StrictSemVerRejectsAmbiguousVersions(string value) =>
|
||||
Assert.False(LauncherVersion.TryParse(value, out _));
|
||||
|
||||
[Fact]
|
||||
public void StrictSemVerIsBoundedAgainstManifestPathAmplification() =>
|
||||
Assert.False(LauncherVersion.TryParse(
|
||||
"1.0.0+" + new string('a', 129),
|
||||
out _));
|
||||
}
|
||||
|
||||
public sealed class ReleaseManifestClientTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task FetchesStrictManifestFromLoopbackAndPinsProductionFeed()
|
||||
{
|
||||
using var server = new LocalHttpFixture();
|
||||
byte[] client = UpdateTestData.ClientZip("win-x64");
|
||||
byte[] launcher = UpdateTestData.LauncherZip("win-x64");
|
||||
server.Add("client.zip", client);
|
||||
server.Add("launcher.zip", launcher);
|
||||
server.Add(
|
||||
"manifest.json",
|
||||
UpdateTestData.Manifest(
|
||||
"2.1.0",
|
||||
"1.5.0",
|
||||
"win-x64",
|
||||
server.UriFor("client.zip"),
|
||||
client,
|
||||
server.UriFor("launcher.zip"),
|
||||
launcher),
|
||||
contentType: "application/json");
|
||||
using var http = new HttpClient();
|
||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
||||
|
||||
ReleaseManifest manifest = await source.FetchAsync();
|
||||
|
||||
Assert.Equal("2.1.0", manifest.Version.Value);
|
||||
Assert.Equal(client.LongLength, manifest.RequireClient("win-x64").Size);
|
||||
Assert.Equal("eriknihlen", ReleaseManifestClient.GitHubOwner);
|
||||
Assert.Equal("acdream", ReleaseManifestClient.GitHubRepository);
|
||||
Assert.Equal(
|
||||
"https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json",
|
||||
ReleaseManifestClient.ProductionManifestUri.AbsoluteUri);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[MemberData(nameof(InvalidManifests))]
|
||||
public void RejectsWrongVersionRidHashSizeMinimumAndUnknownOrDuplicateFields(
|
||||
string json)
|
||||
{
|
||||
Assert.Throws<LauncherUpdateException>(() =>
|
||||
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
|
||||
}
|
||||
|
||||
public static TheoryData<string> InvalidManifests => new()
|
||||
{
|
||||
"{}",
|
||||
ValidJson().Replace("\"schemaVersion\":1", "\"schemaVersion\":2"),
|
||||
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"02.0.0\""),
|
||||
ValidJson().Replace("\"minimumLauncherVersion\":\"1.0.0\"", "\"minimumLauncherVersion\":\"3.0.0\""),
|
||||
ValidJson().Replace("win-x64", "WIN_X64"),
|
||||
ValidJson().Replace(new string('a', 64), "1234"),
|
||||
ValidJson().Replace("\"size\":12", "\"size\":0"),
|
||||
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
|
||||
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
|
||||
ValidJson().Replace("http://127.0.0.1", "http://example.test"),
|
||||
};
|
||||
|
||||
private static string ValidJson() =>
|
||||
"{\"schemaVersion\":1,\"version\":\"2.0.0\","
|
||||
+ "\"minimumLauncherVersion\":\"1.0.0\","
|
||||
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\","
|
||||
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
|
||||
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\","
|
||||
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}";
|
||||
}
|
||||
|
||||
public sealed class VerifiedArtifactDownloaderTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-download-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StreamsToStagingWithProgressAndExactDigest()
|
||||
{
|
||||
using var server = new LocalHttpFixture();
|
||||
byte[] bytes = Enumerable.Range(0, 200_000).Select(value => (byte)value).ToArray();
|
||||
server.Add("artifact", bytes, chunkSize: 4096);
|
||||
using var http = new HttpClient();
|
||||
var downloader = new VerifiedArtifactDownloader(http);
|
||||
var progress = new List<ArtifactDownloadProgress>();
|
||||
string destination = Path.Combine(_root, "artifact.zip");
|
||||
|
||||
VerifiedArtifactDownload result = await downloader.DownloadAsync(
|
||||
new ReleaseArtifact(server.UriFor("artifact"), UpdateTestData.Sha256(bytes), bytes.LongLength),
|
||||
destination,
|
||||
new ImmediateProgress(progress.Add));
|
||||
|
||||
Assert.Equal(bytes.LongLength, result.Size);
|
||||
Assert.Equal(UpdateTestData.Sha256(bytes), result.Sha256);
|
||||
Assert.Equal(bytes, await File.ReadAllBytesAsync(destination));
|
||||
Assert.Equal(0, progress[0].BytesReceived);
|
||||
Assert.Equal(bytes.LongLength, progress[^1].BytesReceived);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("short")]
|
||||
[InlineData("header")]
|
||||
[InlineData("hash")]
|
||||
public async Task WrongSizeHeaderPartialBodyAndHashDeleteStaging(string failure)
|
||||
{
|
||||
using var server = new LocalHttpFixture();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes("verified bytes");
|
||||
long expected = failure == "short" ? bytes.Length + 5 : bytes.Length;
|
||||
long declared = failure == "header" ? bytes.Length + 1 : expected;
|
||||
server.Add("artifact", bytes, declaredLength: declared);
|
||||
using var http = new HttpClient();
|
||||
var downloader = new VerifiedArtifactDownloader(http);
|
||||
string destination = Path.Combine(_root, failure + ".zip");
|
||||
string hash = failure == "hash" ? new string('0', 64) : UpdateTestData.Sha256(bytes);
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() => downloader.DownloadAsync(
|
||||
new ReleaseArtifact(server.UriFor("artifact"), hash, expected),
|
||||
destination));
|
||||
|
||||
Assert.False(File.Exists(destination));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationDeletesPartialStaging()
|
||||
{
|
||||
using var server = new LocalHttpFixture();
|
||||
byte[] bytes = new byte[2 * 1024 * 1024];
|
||||
Random.Shared.NextBytes(bytes);
|
||||
server.Add("slow", bytes, chunkSize: 1024, chunkDelay: TimeSpan.FromMilliseconds(3));
|
||||
using var http = new HttpClient();
|
||||
var downloader = new VerifiedArtifactDownloader(http);
|
||||
string destination = Path.Combine(_root, "cancel.zip");
|
||||
using var cancel = new CancellationTokenSource(TimeSpan.FromMilliseconds(40));
|
||||
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(() => downloader.DownloadAsync(
|
||||
new ReleaseArtifact(server.UriFor("slow"), UpdateTestData.Sha256(bytes), bytes.LongLength),
|
||||
destination,
|
||||
cancellationToken: cancel.Token));
|
||||
|
||||
Assert.False(File.Exists(destination));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefusesAndPreservesPreExistingCallerFile()
|
||||
{
|
||||
using var server = new LocalHttpFixture();
|
||||
byte[] bytes = Encoding.UTF8.GetBytes("network");
|
||||
server.Add("artifact", bytes);
|
||||
using var http = new HttpClient();
|
||||
var downloader = new VerifiedArtifactDownloader(http);
|
||||
string destination = Path.Combine(_root, "already-owned.zip");
|
||||
Directory.CreateDirectory(_root);
|
||||
await File.WriteAllTextAsync(destination, "preserve");
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() => downloader.DownloadAsync(
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("artifact"),
|
||||
UpdateTestData.Sha256(bytes),
|
||||
bytes.LongLength),
|
||||
destination));
|
||||
|
||||
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
|
||||
}
|
||||
|
||||
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
|
||||
: IProgress<ArtifactDownloadProgress>
|
||||
{
|
||||
public void Report(ArtifactDownloadProgress value) => callback(value);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue