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(() => ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json))); } public static TheoryData 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(); 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(() => 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(() => 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(() => 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 callback) : IProgress { public void Report(ArtifactDownloadProgress value) => callback(value); } }