feat(launcher): implement verified atomic updates

This commit is contained in:
Erik 2026-08-14 22:09:34 +02:00
parent 2198a0cc8e
commit 2d2a5b5046
34 changed files with 6755 additions and 61 deletions

View file

@ -2,6 +2,7 @@ using AcDream.Launcher.Core.Launching;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Status;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Orchestration;
@ -34,6 +35,26 @@ public sealed class LauncherOrchestratorTests : IDisposable
}
}
[Fact]
public async Task RunningHostHoldsSharedUpdateLeaseUntilProcessTerminalState()
{
var supervisors = new FakeSupervisorFactory();
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
using LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
updateSessionBarrier: barrier);
_ = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Gui);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
Assert.Single(supervisors.Created).Exit(0);
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
}
[Fact]
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
{
@ -527,7 +548,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
ILauncherSessionConfigService? configService = null,
ILauncherProcessSupervisorFactory? supervisorFactory = null,
IStatusEventSourceFactory? statusSourceFactory = null,
LauncherExecutableSet? executables = null)
LauncherExecutableSet? executables = null,
UpdateSessionBarrier? updateSessionBarrier = null)
{
string profilePath = Path.Combine(
_paths.ConfigDirectory,
@ -563,7 +585,8 @@ public sealed class LauncherOrchestratorTests : IDisposable
configService,
supervisorFactory ?? new FakeSupervisorFactory(),
statusSourceFactory ?? new QueueStatusSourceFactory(),
() => $"s{Interlocked.Increment(ref nextSession)}");
() => $"s{Interlocked.Increment(ref nextSession)}",
updateSessionBarrier: updateSessionBarrier);
orchestrator.LoadProfiles();
return orchestrator;
}

View file

@ -0,0 +1,207 @@
using System.Text;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Profiles;
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class ClientVersionStoreTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-client-version-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _rid;
public ClientVersionStoreTests()
{
_paths = UpdateTestData.Paths(_root);
_rid = LauncherRuntimeIdentity.DetectRid();
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task AtomicPromotionPublishesStrictPointerAndRetainsPreviousForRollback()
{
var store = new ClientVersionStore(_paths);
ClientVersionResolution first = await PromoteAsync(store, "1.0.0", "first");
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "second");
Assert.True(first.IsVerified);
Assert.True(second.IsVerified);
Assert.Equal("2.0.0", second.Version!.Value);
Assert.Equal("1.0.0", second.PreviousVersion);
Assert.True(Directory.Exists(store.GetVersionDirectory(LauncherVersion.Parse("1.0.0"))));
Assert.True(Directory.Exists(store.GetVersionDirectory(LauncherVersion.Parse("2.0.0"))));
Assert.Empty(Directory.EnumerateFileSystemEntries(
store.AppDirectory,
".client-staging-*",
SearchOption.TopDirectoryOnly));
string pointer = await File.ReadAllTextAsync(store.CurrentPointerPath);
Assert.Contains("\"schemaVersion\": 1", pointer, StringComparison.Ordinal);
Assert.Contains("\"currentVersion\": \"2.0.0\"", pointer, StringComparison.Ordinal);
Assert.DoesNotContain(".client-staging", pointer, StringComparison.Ordinal);
ClientVersionResolution rolledBack = await store.RollbackAsync(_rid);
Assert.Equal("1.0.0", rolledBack.Version!.Value);
Assert.Equal("2.0.0", rolledBack.PreviousVersion);
Assert.Equal("first-gui", await File.ReadAllTextAsync(
Path.Combine(rolledBack.Directory!, "AcDream.App" + ExecutableSuffix)));
}
[Fact]
public async Task TornCurrentPointerRecoversLastDurablePointerAndOwnedTempResidue()
{
var store = new ClientVersionStore(_paths);
_ = await PromoteAsync(store, "1.0.0", "first");
_ = await PromoteAsync(store, "2.0.0", "second");
string temp = Path.Combine(
store.AppDirectory,
$".current.json.{Guid.NewGuid():N}.tmp");
await File.WriteAllTextAsync(temp, "partial temp");
await File.WriteAllTextAsync(store.CurrentPointerPath, "{\"schemaVersion\":1,");
var recoveredStore = new ClientVersionStore(_paths);
ClientVersionResolution recovered = await recoveredStore.LoadAndRecoverAsync(_rid);
Assert.True(recovered.IsVerified);
Assert.Equal("1.0.0", recovered.Version!.Value);
Assert.Contains("Recovered", recovered.Status, StringComparison.Ordinal);
Assert.False(File.Exists(temp));
Assert.Contains("\"currentVersion\": \"1.0.0\"", await File.ReadAllTextAsync(
recoveredStore.CurrentPointerPath), StringComparison.Ordinal);
}
[Fact]
public async Task CorruptActiveInstallFailsClosedButVerifiedPreviousCanRollback()
{
var store = new ClientVersionStore(_paths);
_ = await PromoteAsync(store, "1.0.0", "first");
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "second");
string graphical = Path.Combine(second.Directory!, "AcDream.App" + ExecutableSuffix);
await File.WriteAllTextAsync(graphical, "tampered!!");
var restarted = new ClientVersionStore(_paths);
ClientVersionResolution invalid = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, invalid.State);
Assert.Contains("corrupt", invalid.Status, StringComparison.OrdinalIgnoreCase);
ClientVersionResolution rolledBack = await restarted.RollbackAsync(_rid);
Assert.Equal("1.0.0", rolledBack.Version!.Value);
}
[Fact]
public async Task StrictPointerAndInstallRecordsRejectUnknownUnrecordedAndWrongRidState()
{
var store = new ClientVersionStore(_paths);
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "strict");
await File.WriteAllTextAsync(
Path.Combine(installed.Directory!, "unrecorded.dll"),
"unexpected");
var restarted = new ClientVersionStore(_paths);
ClientVersionResolution unrecorded = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, unrecorded.State);
Assert.Contains("unrecorded", unrecorded.Status, StringComparison.OrdinalIgnoreCase);
File.Delete(Path.Combine(installed.Directory!, "unrecorded.dll"));
string installPath = ClientVersionStore.GetMetadataPath(installed.Directory!);
string install = await File.ReadAllTextAsync(installPath);
await File.WriteAllTextAsync(
installPath,
install.Replace(
"\"schemaVersion\": 1",
"\"schemaVersion\": 1,\"schemaVersion\": 1",
StringComparison.Ordinal));
ClientVersionResolution duplicate = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, duplicate.State);
Assert.Contains("Duplicate", duplicate.Status, StringComparison.Ordinal);
await File.WriteAllTextAsync(installPath, install);
string current = await File.ReadAllTextAsync(store.CurrentPointerPath);
await File.WriteAllTextAsync(
store.CurrentPointerPath,
current.TrimEnd().TrimEnd('}') + ",\"unknown\":true}");
ClientVersionResolution unknown = await restarted.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, unknown.State);
await File.WriteAllTextAsync(store.CurrentPointerPath, current);
string otherRid = _rid == "win-x64" ? "linux-x64" : "win-x64";
ClientVersionResolution wrongRid = await restarted.LoadAndRecoverAsync(otherRid);
Assert.Equal(ClientVersionState.Invalid, wrongRid.State);
Assert.Contains("RID", wrongRid.Status, StringComparison.Ordinal);
}
[Fact]
public async Task DynamicExecutableResolverTracksOnlyVerifiedCurrentVersion()
{
var store = new ClientVersionStore(_paths);
LauncherExecutableSet executables = LauncherExecutableSet.FromCurrentVersionStore(store);
Assert.False(executables.GetAvailability(LaunchMode.Gui).IsAvailable);
ClientVersionResolution first = await PromoteAsync(store, "1.0.0", "one");
Assert.Equal(first.Directory, executables.WorkingDirectory);
Assert.Equal(
Path.Combine(first.Directory!, "AcDream.App" + ExecutableSuffix),
executables.CreatePlaySpec(LaunchMode.Gui, "session.json").ExecutablePath);
ClientVersionResolution second = await PromoteAsync(store, "2.0.0", "two");
Assert.Equal(second.Directory, executables.WorkingDirectory);
Assert.Equal(
Path.Combine(second.Directory!, "acdream-headless" + ExecutableSuffix),
executables.CreateProbeSpec("session.json").ExecutablePath);
}
[Fact]
public async Task ExistingSemanticVersionCannotReplaceActiveContentInPlace()
{
var store = new ClientVersionStore(_paths);
_ = await PromoteAsync(store, "1.0.0", "original");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
PromoteAsync(store, "1.0.0", "different"));
Assert.Contains("active client version", error.Message, StringComparison.OrdinalIgnoreCase);
ClientVersionResolution resolution = await store.LoadAndRecoverAsync(_rid);
Assert.Equal("original-gui", await File.ReadAllTextAsync(
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
}
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
private async Task<ClientVersionResolution> PromoteAsync(
ClientVersionStore store,
string versionText,
string marker)
{
byte[] archive = UpdateTestData.ClientZip(_rid, marker);
string zipPath = Path.Combine(_root, $"{versionText}-{marker}.zip");
Directory.CreateDirectory(_root);
await File.WriteAllBytesAsync(zipPath, archive);
string staging = store.CreateClientStagingDirectory(Guid.NewGuid());
IReadOnlyList<ExtractedFileRecord> files = await new SafeZipExtractor()
.ExtractAsync(zipPath, staging);
using UpdateSessionBarrier.ExclusiveLease lease = store.Barrier.AcquireExclusive();
return await store.PromoteAndActivateUnderLeaseAsync(
staging,
LauncherVersion.Parse(versionText),
_rid,
new ReleaseArtifact(
new Uri($"https://example.test/{versionText}.zip"),
UpdateTestData.Sha256(archive),
archive.LongLength),
files);
}
}

View file

@ -0,0 +1,279 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherSelfUpdateManagerTests : IDisposable
{
private static readonly JsonSerializerOptions PlanOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter<SelfUpdatePlanState>(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-self-update-tests",
"target & $(literal)-" + Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task StartupWithoutPlanReclaimsOnlyExactOwnedResidue()
{
using var harness = new Harness(_root);
string orphan = harness.Manager.GetTransactionDirectory(Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(orphan);
await File.WriteAllTextAsync(Path.Combine(orphan, "partial"), "partial");
Directory.CreateDirectory(harness.Manager.RootDirectory);
string temporary = Path.Combine(
harness.Manager.RootDirectory,
$".pending.json.{Guid.NewGuid():N}.tmp");
string unrelated = Path.Combine(harness.Manager.RootDirectory, "pending.user.tmp");
await File.WriteAllTextAsync(temporary, "partial");
await File.WriteAllTextAsync(unrelated, "preserve");
Assert.Null(await harness.Manager.LoadPendingAsync());
Assert.False(Directory.Exists(orphan));
Assert.False(File.Exists(temporary));
Assert.True(File.Exists(unrelated));
}
[Fact]
public async Task VerifiedStageIsDurableAndDoesNotTouchRunningTarget()
{
using var harness = new Harness(_root);
SelfUpdateStageResult result = await harness.StageAsync();
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
Assert.Equal("2.0.0", result.Version.Value);
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
Assert.Null(plan.Apply);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(
Path.Combine(harness.Manager.GetPayloadDirectory(plan.TransactionId), harness.LauncherName)));
string pendingJson = await File.ReadAllTextAsync(result.PendingPlanPath);
Assert.Contains("\"state\": \"staged\"", pendingJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"state\": \"Staged\"", pendingJson, StringComparison.Ordinal);
Assert.DoesNotContain("cmd", pendingJson,
StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ApplyConfirmAndCompletionUseMoveJournalAndRemoveTransaction()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, applied.State);
Assert.NotNull(applied.Apply);
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("support-new-launcher", await File.ReadAllTextAsync(harness.SupportPath));
await harness.Manager.ConfirmAsync(
applied.TransactionId,
harness.Target,
harness.LauncherPath);
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
Assert.False(Directory.Exists(
harness.Manager.GetTransactionDirectory(applied.TransactionId)));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
}
[Fact]
public async Task AwaitingConfirmationRollbackRestoresEveryOldFileAndCanRetry()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdatePlan rolledBack = await harness.Manager
.RollbackAwaitingConfirmationAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
Assert.Null(rolledBack.Apply);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.AwaitingConfirmation, retried.State);
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
}
[Fact]
public async Task CrashDuringApplyingReplaysReverseJournalToStagedState()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
SelfUpdateApplyEntry[] apply = staged.Files
.Select(file => new SelfUpdateApplyEntry(
file.Path,
File.Exists(Path.Combine(
harness.Target,
file.Path.Replace('/', Path.DirectorySeparatorChar)))))
.ToArray();
SelfUpdatePlan applying = staged with
{
State = SelfUpdatePlanState.Applying,
Apply = apply,
};
await File.WriteAllTextAsync(
harness.Manager.PendingPlanPath,
JsonSerializer.Serialize(applying, PlanOptions));
SelfUpdateApplyEntry first = apply[0];
string payload = Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId),
first.Path.Replace('/', Path.DirectorySeparatorChar));
string target = Path.Combine(
harness.Target,
first.Path.Replace('/', Path.DirectorySeparatorChar));
string backup = Path.Combine(
harness.Manager.GetBackupDirectory(staged.TransactionId),
first.Path.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
File.Move(target, backup);
File.Move(payload, target);
SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target);
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId),
harness.LauncherName)));
}
[Fact]
public async Task CorruptPayloadWrongTargetAndUnknownPlanFieldFailClosed()
{
using var harness = new Harness(_root);
_ = await harness.StageAsync();
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
await harness.Manager.LoadPendingAsync());
await File.WriteAllTextAsync(
Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId),
harness.LauncherName),
"bad-payload!");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.ApplyPendingAsync(harness.Target));
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
string wrongTarget = Path.Combine(_root, "other-target");
Directory.CreateDirectory(wrongTarget);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.ApplyPendingAsync(wrongTarget));
string json = await File.ReadAllTextAsync(harness.Manager.PendingPlanPath);
await File.WriteAllTextAsync(
harness.Manager.PendingPlanPath,
json.TrimEnd().TrimEnd('}') + ",\"unknown\":true}");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
harness.Manager.LoadPendingAsync());
}
[Fact]
public async Task BootstrapConfirmationAndOrdinaryStartupDoNotUseShellParsing()
{
using var harness = new Harness(_root);
SelfUpdateStartupResult ordinary = await LauncherSelfUpdateBootstrap.HandleAsync(
["--literal", "argument with spaces & metacharacters"],
harness.Manager,
harness.Target,
harness.LauncherPath);
Assert.False(ordinary.ShouldExit);
Assert.Equal(["--literal", "argument with spaces & metacharacters"],
ordinary.RemainingArguments);
_ = await harness.StageAsync();
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
SelfUpdateStartupResult confirmation = await LauncherSelfUpdateBootstrap.HandleAsync(
[LauncherSelfUpdateBootstrap.ConfirmArgument, applied.TransactionId],
harness.Manager,
harness.Target,
harness.LauncherPath);
Assert.False(confirmation.ShouldExit);
Assert.Empty(confirmation.RemainingArguments);
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
}
private sealed class Harness : IDisposable
{
private readonly LocalHttpFixture _server = new();
private readonly HttpClient _http = new();
private readonly byte[] _archive;
private readonly ReleaseArtifact _artifact;
public Harness(string root)
{
Target = Path.Combine(root, "published launcher");
Directory.CreateDirectory(Target);
Rid = LauncherRuntimeIdentity.DetectRid();
LauncherName = "acdream-launcher"
+ (Rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
LauncherPath = Path.Combine(Target, LauncherName);
SupportPath = Path.Combine(Target, "support.dat");
File.WriteAllText(LauncherPath, "old-launcher");
File.WriteAllText(SupportPath, "old-support");
_archive = UpdateTestData.LauncherZip(Rid, "new-launcher");
_server.Add("launcher.zip", _archive);
_artifact = new ReleaseArtifact(
_server.UriFor("launcher.zip"),
UpdateTestData.Sha256(_archive),
_archive.LongLength);
Manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(root), _http);
}
public string Target { get; }
public string Rid { get; }
public string LauncherName { get; }
public string LauncherPath { get; }
public string SupportPath { get; }
public LauncherSelfUpdateManager Manager { get; }
public Task<SelfUpdateStageResult> StageAsync() => Manager.StageAsync(
LauncherVersion.Parse("2.0.0"),
Rid,
_artifact,
Target,
progress: null,
CancellationToken.None);
public void Dispose()
{
_http.Dispose();
_server.Dispose();
}
}
}

View file

@ -0,0 +1,211 @@
using AcDream.Launcher.Core.Updates;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherUpdaterIntegrationTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-updater-integration-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _rid = LauncherRuntimeIdentity.DetectRid();
public LauncherUpdaterIntegrationTests() => _paths = UpdateTestData.Paths(_root);
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task LocalHttpManifestDownloadExtractPromotionAndNextCheckAreCoherent()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid, "release-2");
byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2");
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"));
_ = await updater.InitializeAsync();
var progress = new List<LauncherUpdateProgress>();
LauncherUpdateCheckResult check = await updater.CheckAsync();
ClientVersionResolution installed = await updater.InstallClientAsync(
check,
new ImmediateProgress(progress.Add));
LauncherUpdateCheckResult after = await updater.CheckAsync();
Assert.True(check.IsClientUpdateAvailable);
Assert.True(check.IsLauncherUpdateAvailable);
Assert.True(check.IsLauncherMinimumSatisfied);
Assert.True(installed.IsVerified);
Assert.Equal("2.0.0", installed.Version!.Value);
Assert.False(after.IsClientUpdateAvailable);
Assert.True(after.IsLauncherUpdateAvailable);
Assert.Contains(progress, item => item.Phase == LauncherUpdatePhase.DownloadingClient);
Assert.Contains(progress, item => item.Phase == LauncherUpdatePhase.ExtractingClient);
Assert.Contains(progress, item => item.Phase == LauncherUpdatePhase.ActivatingClient);
Assert.Equal(LauncherUpdatePhase.Completed, progress[^1].Phase);
Assert.Empty(Directory.EnumerateFiles(
versions.AppDirectory,
".client-download-*",
SearchOption.TopDirectoryOnly));
}
[Fact]
public async Task MinimumLauncherGateAndMissingRidFailBeforePublication()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid);
byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"));
_ = await updater.InitializeAsync();
LauncherUpdateCheckResult check = await updater.CheckAsync();
Assert.False(check.IsLauncherMinimumSatisfied);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
updater.InstallClientAsync(check));
Assert.False(File.Exists(versions.CurrentPointerPath));
string otherRid = _rid == "win-x64" ? "linux-x64" : "win-x64";
ConfigureRelease(server, "3.0.0", "1.0.0", otherRid,
UpdateTestData.ClientZip(otherRid), UpdateTestData.LauncherZip(otherRid));
LauncherUpdateException missingRid = await Assert.ThrowsAsync<LauncherUpdateException>(
() => updater.CheckAsync());
Assert.Contains(_rid, missingRid.Message, StringComparison.Ordinal);
}
[Fact]
public async Task RunningPredicateAndCrossProcessBarrierRefuseUpdateWithoutNetworkMutation()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid);
byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths);
bool running = true;
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"),
() => running);
_ = await updater.InitializeAsync();
LauncherUpdateCheckResult check = await updater.CheckAsync();
LauncherUpdateException local = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
updater.InstallClientAsync(check));
Assert.Contains("Stop every", local.Message, StringComparison.Ordinal);
Assert.False(File.Exists(versions.CurrentPointerPath));
running = false;
using UpdateSessionBarrier.SessionLease session = versions.Barrier.AcquireSession();
LauncherUpdateException shared = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
updater.InstallClientAsync(check));
Assert.Contains("session", shared.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(File.Exists(versions.CurrentPointerPath));
}
[Fact]
public async Task CancelledSlowClientDownloadLeavesNoPointerOrStaging()
{
using var server = new LocalHttpFixture();
byte[] client = UpdateTestData.ClientZip(_rid, new string('x', 1_000_000));
byte[] launcher = UpdateTestData.LauncherZip(_rid);
server.Add("client.zip", client, chunkSize: 1024, chunkDelay: TimeSpan.FromMilliseconds(2));
server.Add("launcher.zip", launcher);
server.Add(
"manifest.json",
UpdateTestData.Manifest(
"2.0.0",
"1.0.0",
_rid,
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"));
var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater(
source,
http,
versions,
new LauncherSelfUpdateManager(_paths, http),
LauncherVersion.Parse("1.0.0"),
_rid,
Path.Combine(_root, "launcher"));
_ = await updater.InitializeAsync();
LauncherUpdateCheckResult check = await updater.CheckAsync();
using var cancellation = new CancellationTokenSource(TimeSpan.FromMilliseconds(30));
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
updater.InstallClientAsync(check, cancellationToken: cancellation.Token));
Assert.False(File.Exists(versions.CurrentPointerPath));
Assert.Empty(Directory.EnumerateFileSystemEntries(
versions.AppDirectory,
".client-*",
SearchOption.TopDirectoryOnly));
}
private static void ConfigureRelease(
LocalHttpFixture server,
string version,
string minimum,
string rid,
byte[] client,
byte[] launcher)
{
server.Add("client.zip", client);
server.Add("launcher.zip", launcher);
server.Add(
"manifest.json",
UpdateTestData.Manifest(
version,
minimum,
rid,
server.UriFor("client.zip"),
client,
server.UriFor("launcher.zip"),
launcher),
contentType: "application/json");
}
private sealed class ImmediateProgress(Action<LauncherUpdateProgress> callback)
: IProgress<LauncherUpdateProgress>
{
public void Report(LauncherUpdateProgress value) => callback(value);
}
}

View file

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

View file

@ -0,0 +1,186 @@
using System.IO.Compression;
using System.Text;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class SafeZipExtractorTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-safe-zip-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task ExtractsPortableTreeWithHashesAndExecutableMode()
{
byte[] archive = UpdateTestData.CreateZip(
[
("bin/", [], 0x41ED),
("bin/client", Encoding.UTF8.GetBytes("client"), 0x81ED),
("data/value.txt", Encoding.UTF8.GetBytes("value"), 0x81A4),
]);
string zip = WriteArchive("valid.zip", archive);
string destination = Path.Combine(_root, "valid");
IReadOnlyList<ExtractedFileRecord> files = await new SafeZipExtractor()
.ExtractAsync(zip, destination);
Assert.Equal(["bin/client", "data/value.txt"], files.Select(file => file.Path));
Assert.Equal(0x1ED, files[0].UnixMode);
Assert.Equal(UpdateTestData.Sha256(Encoding.UTF8.GetBytes("client")), files[0].Sha256);
Assert.Equal("value", await File.ReadAllTextAsync(Path.Combine(destination, "data", "value.txt")));
}
[Theory]
[InlineData("../escape")]
[InlineData("a/../../escape")]
[InlineData("/rooted")]
[InlineData("C:/drive")]
[InlineData("file:stream")]
[InlineData("a//b")]
[InlineData("a/./b")]
[InlineData("CON")]
[InlineData("aux.txt")]
[InlineData("trailing.")]
[InlineData("trailing ")]
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
{
string zip = WriteArchive(
"unsafe-" + Guid.NewGuid().ToString("N") + ".zip",
UpdateTestData.CreateZip([(entry, Encoding.UTF8.GetBytes("bad"), 0x81A4)]));
string destination = Path.Combine(_root, "unsafe-" + Guid.NewGuid().ToString("N"));
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
Assert.False(File.Exists(Path.Combine(_root, "escape")));
}
[Fact]
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
{
byte[][] archives =
[
UpdateTestData.CreateZip(
[
("Readme.txt", Encoding.UTF8.GetBytes("a"), 0x81A4),
("README.TXT", Encoding.UTF8.GetBytes("b"), 0x81A4),
]),
UpdateTestData.CreateZip(
[
("node", Encoding.UTF8.GetBytes("file"), 0x81A4),
("node/child", Encoding.UTF8.GetBytes("child"), 0x81A4),
]),
UpdateTestData.CreateZip(
[
("Folder/one", Encoding.UTF8.GetBytes("one"), 0x81A4),
("folder/two", Encoding.UTF8.GetBytes("two"), 0x81A4),
]),
];
foreach (byte[] archive in archives)
{
string id = Guid.NewGuid().ToString("N");
string zip = WriteArchive(id + ".zip", archive);
string destination = Path.Combine(_root, id);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
}
}
[Fact]
public async Task RejectsSymlinkAndReparseMetadata()
{
byte[] symlink = UpdateTestData.CreateZip(
[("link", Encoding.UTF8.GetBytes("../../outside"), 0xA1FF)]);
string zip = WriteArchive("symlink.zip", symlink);
string destination = Path.Combine(_root, "symlink");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => new SafeZipExtractor().ExtractAsync(zip, destination));
Assert.Contains("symlink", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.False(Directory.Exists(destination));
}
[Fact]
public async Task RejectsEntryCountSizeTotalAndCompressionRatioBombs()
{
var cases = new (byte[] Archive, SafeZipExtractionLimits Limits)[]
{
(
UpdateTestData.CreateZip(
[
("one", [1], 0x81A4),
("two", [2], 0x81A4),
]),
new SafeZipExtractionLimits(MaximumEntries: 1)),
(
UpdateTestData.CreateZip([("large", new byte[8], 0x81A4)]),
new SafeZipExtractionLimits(MaximumEntryBytes: 7)),
(
UpdateTestData.CreateZip(
[
("one", new byte[6], 0x81A4),
("two", new byte[6], 0x81A4),
]),
new SafeZipExtractionLimits(MaximumTotalBytes: 10)),
(
UpdateTestData.CreateZip(
[("ratio", new byte[64 * 1024], 0x81A4)],
CompressionLevel.SmallestSize),
new SafeZipExtractionLimits(MaximumCompressionRatio: 2)),
};
foreach ((byte[] archive, SafeZipExtractionLimits limits) in cases)
{
string id = Guid.NewGuid().ToString("N");
string zip = WriteArchive(id + ".zip", archive);
string destination = Path.Combine(_root, id);
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor(limits).ExtractAsync(zip, destination));
Assert.False(Directory.Exists(destination));
}
}
[Fact]
public async Task ExistingNonEmptyDestinationAndCancellationNeverPublishPartialTree()
{
string zip = WriteArchive(
"cancel.zip",
UpdateTestData.CreateZip([("large", new byte[1024 * 1024], 0x81A4)]));
string nonEmpty = Path.Combine(_root, "nonempty");
Directory.CreateDirectory(nonEmpty);
await File.WriteAllTextAsync(Path.Combine(nonEmpty, "owner"), "preserve");
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
new SafeZipExtractor().ExtractAsync(zip, nonEmpty));
Assert.Equal("preserve", await File.ReadAllTextAsync(Path.Combine(nonEmpty, "owner")));
string cancelled = Path.Combine(_root, "cancelled");
using var cancellation = new CancellationTokenSource();
cancellation.Cancel();
await Assert.ThrowsAnyAsync<OperationCanceledException>(() =>
new SafeZipExtractor().ExtractAsync(zip, cancelled, cancellation.Token));
Assert.False(Directory.Exists(cancelled));
}
private string WriteArchive(string name, byte[] content)
{
Directory.CreateDirectory(_root);
string path = Path.Combine(_root, name);
File.WriteAllBytes(path, content);
return path;
}
}

View file

@ -0,0 +1,147 @@
using System.Diagnostics;
using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class UpdateSessionBarrierTests : IDisposable
{
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-update-lease-tests",
Guid.NewGuid().ToString("N"));
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public void SharedSessionsCoexistAndExcludeUpdateTransactions()
{
var barrier = new UpdateSessionBarrier(_root);
using UpdateSessionBarrier.SessionLease first = barrier.AcquireSession();
using UpdateSessionBarrier.SessionLease second = barrier.AcquireSession();
LauncherUpdateException blocked = Assert.Throws<LauncherUpdateException>(
barrier.AcquireExclusive);
Assert.Contains("session", blocked.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void ExclusiveUpdaterExcludesSessionsAndConcurrentUpdater()
{
var barrier = new UpdateSessionBarrier(_root);
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
Assert.Throws<LauncherUpdateException>(barrier.AcquireSession);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
}
[Theory]
[InlineData("session")]
[InlineData("exclusive")]
public async Task CrossProcessLeaseRefusesRacingLauncherAndReleasesCleanly(string mode)
{
string ready = Path.Combine(_root, mode + ".ready");
string release = Path.Combine(_root, mode + ".release");
Directory.CreateDirectory(_root);
string fixture = GetFixturePath();
Assert.True(File.Exists(fixture), $"Missing fixture: {fixture}");
var startInfo = new ProcessStartInfo("dotnet")
{
UseShellExecute = false,
RedirectStandardError = true,
RedirectStandardOutput = true,
};
startInfo.ArgumentList.Add(fixture);
startInfo.ArgumentList.Add("hold-update-lease");
startInfo.ArgumentList.Add(mode);
startInfo.ArgumentList.Add(_root);
startInfo.ArgumentList.Add(ready);
startInfo.ArgumentList.Add(release);
using Process holder = Process.Start(startInfo)
?? throw new InvalidOperationException("Could not start update lease fixture.");
try
{
await WaitForFileAsync(ready, holder);
var barrier = new UpdateSessionBarrier(_root);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
if (mode == "session")
{
using UpdateSessionBarrier.SessionLease peer = barrier.AcquireSession();
}
else
{
Assert.Throws<LauncherUpdateException>(barrier.AcquireSession);
}
await File.WriteAllTextAsync(release, "release");
await holder.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(0, holder.ExitCode);
using UpdateSessionBarrier.ExclusiveLease after = barrier.AcquireExclusive();
}
finally
{
if (!holder.HasExited)
{
holder.Kill(entireProcessTree: true);
await holder.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
}
}
}
private static async Task WaitForFileAsync(string path, Process process)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(10);
while (!File.Exists(path))
{
if (process.HasExited)
{
throw new InvalidOperationException(
$"Lease fixture exited early with {process.ExitCode}: "
+ await process.StandardError.ReadToEndAsync());
}
if (DateTimeOffset.UtcNow >= deadline)
{
throw new TimeoutException("Lease fixture did not become ready.");
}
await Task.Delay(20);
}
}
private static string GetFixturePath()
{
string root = FindRepositoryRoot();
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
.Parent?.Name ?? "Release";
return Path.Combine(
root,
"tests",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
"bin",
configuration,
"net10.0",
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder.dll");
}
private static string FindRepositoryRoot()
{
for (var directory = new DirectoryInfo(AppContext.BaseDirectory);
directory is not null;
directory = directory.Parent)
{
if (File.Exists(Path.Combine(directory.FullName, "AcDream.slnx")))
{
return directory.FullName;
}
}
throw new InvalidOperationException("Repository root was not found.");
}
}

View file

@ -0,0 +1,270 @@
using System.IO.Compression;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Updates;
internal sealed class LocalHttpFixture : IDisposable
{
private readonly TcpListener _listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource _stop = new();
private readonly Dictionary<string, Response> _responses =
new(StringComparer.Ordinal);
private readonly Task _server;
public LocalHttpFixture()
{
_listener.Start();
int port = ((IPEndPoint)_listener.LocalEndpoint).Port;
BaseUri = new Uri($"http://127.0.0.1:{port}/", UriKind.Absolute);
_server = ServeAsync();
}
public Uri BaseUri { get; }
public Uri UriFor(string relative) => new(BaseUri, relative.TrimStart('/'));
public void Add(
string path,
byte[] body,
long? declaredLength = null,
int chunkSize = int.MaxValue,
TimeSpan? chunkDelay = null,
string contentType = "application/octet-stream")
{
_responses[NormalizePath(path)] = new Response(
body,
declaredLength ?? body.LongLength,
chunkSize,
chunkDelay ?? TimeSpan.Zero,
contentType);
}
public void Dispose()
{
_stop.Cancel();
_listener.Stop();
try
{
_server.Wait(TimeSpan.FromSeconds(2));
}
catch (AggregateException)
{
// Listener cancellation is the expected shutdown path.
}
_stop.Dispose();
}
private async Task ServeAsync()
{
while (!_stop.IsCancellationRequested)
{
TcpClient client;
try
{
client = await _listener.AcceptTcpClientAsync(_stop.Token);
}
catch (OperationCanceledException)
{
return;
}
catch (ObjectDisposedException)
{
return;
}
catch (SocketException) when (_stop.IsCancellationRequested)
{
return;
}
_ = Task.Run(() => RespondAsync(client), CancellationToken.None);
}
}
private async Task RespondAsync(TcpClient client)
{
using (client)
{
try
{
NetworkStream stream = client.GetStream();
using var reader = new StreamReader(
stream,
Encoding.ASCII,
detectEncodingFromByteOrderMarks: false,
bufferSize: 4096,
leaveOpen: true);
string? request = await reader.ReadLineAsync(_stop.Token);
while (!string.IsNullOrEmpty(await reader.ReadLineAsync(_stop.Token)))
{
}
string path = request?.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.ElementAtOrDefault(1) ?? "/";
if (!_responses.TryGetValue(NormalizePath(path), out Response? response))
{
await WriteHeaderAsync(stream, 404, 0, "text/plain");
return;
}
await WriteHeaderAsync(
stream,
200,
response.DeclaredLength,
response.ContentType);
int offset = 0;
while (offset < response.Body.Length)
{
int count = Math.Min(response.ChunkSize, response.Body.Length - offset);
await stream.WriteAsync(
response.Body.AsMemory(offset, count),
_stop.Token);
await stream.FlushAsync(_stop.Token);
offset += count;
if (offset < response.Body.Length && response.ChunkDelay > TimeSpan.Zero)
{
await Task.Delay(response.ChunkDelay, _stop.Token);
}
}
}
catch (Exception ex) when (ex is IOException
or OperationCanceledException
or ObjectDisposedException
or SocketException)
{
// The downloader cancellation/early-refusal tests close the socket.
}
}
}
private static async Task WriteHeaderAsync(
Stream stream,
int status,
long length,
string contentType)
{
string reason = status == 200 ? "OK" : "Not Found";
byte[] header = Encoding.ASCII.GetBytes(
$"HTTP/1.1 {status} {reason}\r\n"
+ $"Content-Length: {length}\r\n"
+ $"Content-Type: {contentType}\r\n"
+ "Connection: close\r\n\r\n");
await stream.WriteAsync(header);
await stream.FlushAsync();
}
private static string NormalizePath(string path)
{
int query = path.IndexOf('?', StringComparison.Ordinal);
string withoutQuery = query < 0 ? path : path[..query];
return "/" + withoutQuery.TrimStart('/');
}
private sealed record Response(
byte[] Body,
long DeclaredLength,
int ChunkSize,
TimeSpan ChunkDelay,
string ContentType);
}
internal static class UpdateTestData
{
public static ApplicationPathSet Paths(string root) => new(
Path.Combine(root, "config"),
Path.Combine(root, "data"),
Path.Combine(root, "cache"),
null);
public static byte[] CreateZip(
IEnumerable<(string Name, byte[] Content, int? UnixAttributes)> entries,
CompressionLevel compression = CompressionLevel.NoCompression)
{
using var output = new MemoryStream();
using (var archive = new ZipArchive(output, ZipArchiveMode.Create, leaveOpen: true))
{
foreach ((string name, byte[] content, int? unixAttributes) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(name, compression);
if (unixAttributes is int attributes)
{
entry.ExternalAttributes = attributes << 16;
}
if (!name.EndsWith("/", StringComparison.Ordinal))
{
using Stream stream = entry.Open();
stream.Write(content);
}
}
}
return output.ToArray();
}
public static byte[] ClientZip(string rid, string marker = "client")
{
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
const int executable = 0x81ED;
return CreateZip(
[
($"AcDream.App{suffix}", Encoding.UTF8.GetBytes(marker + "-gui"), executable),
($"acdream-headless{suffix}", Encoding.UTF8.GetBytes(marker + "-headless"), executable),
("assets/readme.txt", Encoding.UTF8.GetBytes(marker), 0x81A4),
]);
}
public static byte[] LauncherZip(string rid, string marker = "launcher")
{
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
return CreateZip(
[
($"acdream-launcher{suffix}", Encoding.UTF8.GetBytes(marker), 0x81ED),
("support.dat", Encoding.UTF8.GetBytes("support-" + marker), 0x81A4),
]);
}
public static string Sha256(byte[] bytes) =>
Convert.ToHexStringLower(SHA256.HashData(bytes));
public static byte[] Manifest(
string version,
string minimum,
string rid,
Uri clientUri,
byte[] client,
Uri launcherUri,
byte[] launcher)
{
var value = new
{
schemaVersion = 1,
version,
minimumLauncherVersion = minimum,
clients = new Dictionary<string, object>
{
[rid] = new
{
url = clientUri.AbsoluteUri,
sha256 = Sha256(client),
size = client.LongLength,
},
},
launchers = new Dictionary<string, object>
{
[rid] = new
{
url = launcherUri.AbsoluteUri,
sha256 = Sha256(launcher),
size = launcher.LongLength,
},
},
};
return JsonSerializer.SerializeToUtf8Bytes(value);
}
}