merge: Campaign LA LA10 - updater review-closed
This commit is contained in:
commit
da4fb3de19
40 changed files with 9778 additions and 68 deletions
|
|
@ -2,16 +2,202 @@ using System.Diagnostics;
|
|||
using System.Reflection;
|
||||
using AcDream.Bake;
|
||||
using AcDream.Launcher.Core.Installation;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
|
||||
return args.FirstOrDefault() switch
|
||||
const string SelfUpdateDataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
|
||||
const string SelfUpdateTargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
|
||||
const string SelfUpdateHelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
|
||||
|
||||
string[] effectiveArgs = args;
|
||||
string? selfUpdateData = Environment.GetEnvironmentVariable(SelfUpdateDataEnvironment);
|
||||
string? selfUpdateTarget = Environment.GetEnvironmentVariable(SelfUpdateTargetEnvironment);
|
||||
if (!string.IsNullOrWhiteSpace(selfUpdateData)
|
||||
&& !string.IsNullOrWhiteSpace(selfUpdateTarget)
|
||||
&& IsBootstrapInvocation(effectiveArgs))
|
||||
{
|
||||
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
||||
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
||||
"orphan-child" => RunOrphanChild(args[1..]),
|
||||
if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument
|
||||
&& Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid
|
||||
&& !string.IsNullOrWhiteSpace(helperPid))
|
||||
{
|
||||
File.WriteAllText(
|
||||
Path.GetFullPath(helperPid),
|
||||
Environment.ProcessId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
|
||||
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
effectiveArgs,
|
||||
manager,
|
||||
Path.GetFullPath(selfUpdateTarget),
|
||||
Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
if (startup.ShouldExit)
|
||||
{
|
||||
return startup.ExitCode;
|
||||
}
|
||||
|
||||
effectiveArgs = startup.RemainingArguments;
|
||||
}
|
||||
|
||||
return effectiveArgs.FirstOrDefault() switch
|
||||
{
|
||||
"hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]),
|
||||
"hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]),
|
||||
"orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]),
|
||||
"orphan-child" => RunOrphanChild(effectiveArgs[1..]),
|
||||
"crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]),
|
||||
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
|
||||
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
|
||||
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
|
||||
_ => 2,
|
||||
};
|
||||
|
||||
static bool IsBootstrapInvocation(string[] arguments) =>
|
||||
arguments.Length > 0
|
||||
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
|
||||
or LauncherSelfUpdateBootstrap.ConfirmArgument
|
||||
or LauncherSelfUpdateBootstrap.DeferredArgument
|
||||
or "canonical-probe";
|
||||
|
||||
static ApplicationPathSet Paths(string dataDirectory)
|
||||
{
|
||||
string data = Path.GetFullPath(dataDirectory);
|
||||
return new ApplicationPathSet(
|
||||
Path.Combine(data, "fixture-config"),
|
||||
data,
|
||||
Path.Combine(data, "fixture-cache"),
|
||||
null);
|
||||
}
|
||||
|
||||
static async Task<int> CrashSelfUpdateAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 4)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||
string targetDirectory = Path.GetFullPath(arguments[1]);
|
||||
string readyPath = Path.GetFullPath(arguments[2]);
|
||||
string canonicalName = arguments[3];
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(
|
||||
Paths(dataDirectory),
|
||||
http,
|
||||
null,
|
||||
observation =>
|
||||
{
|
||||
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
|
||||
&& string.Equals(
|
||||
observation.Path,
|
||||
canonicalName,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
if (!File.Exists(Path.Combine(targetDirectory, canonicalName)))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The canonical launcher vanished at the apply boundary.");
|
||||
}
|
||||
|
||||
File.WriteAllText(readyPath, Environment.ProcessId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
Thread.Sleep(Timeout.Infinite);
|
||||
}
|
||||
});
|
||||
using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive();
|
||||
_ = await manager.ApplyPendingAsync(targetDirectory);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> StageSelfUpdateAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 7
|
||||
|| !long.TryParse(
|
||||
arguments[6],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out long size))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http);
|
||||
_ = await manager.StageAsync(
|
||||
LauncherVersion.Parse(arguments[2]),
|
||||
arguments[3],
|
||||
new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size),
|
||||
Path.GetFullPath(arguments[1]),
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> BootstrapProbeAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 4)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
|
||||
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||
["ordinary"],
|
||||
manager,
|
||||
Path.GetFullPath(arguments[1]),
|
||||
Path.GetFullPath(arguments[2]));
|
||||
File.WriteAllText(
|
||||
Path.GetFullPath(arguments[3]),
|
||||
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));
|
||||
return result.ShouldExit ? 3 : 0;
|
||||
}
|
||||
|
||||
static int CanonicalProbe(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 1)
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
File.WriteAllText(
|
||||
Path.GetFullPath(arguments[0]),
|
||||
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||
+ "|"
|
||||
+ Path.GetFullPath(
|
||||
Environment.ProcessPath
|
||||
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 4
|
||||
|| arguments[0] is not ("session" or "exclusive"))
|
||||
{
|
||||
return 2;
|
||||
}
|
||||
|
||||
var barrier = new UpdateSessionBarrier(Path.GetFullPath(arguments[1]));
|
||||
using IDisposable lease = arguments[0] == "session"
|
||||
? barrier.AcquireSession()
|
||||
: barrier.AcquireExclusive();
|
||||
string readyPath = Path.GetFullPath(arguments[2]);
|
||||
string releasePath = Path.GetFullPath(arguments[3]);
|
||||
File.WriteAllText(readyPath, arguments[0]);
|
||||
while (!File.Exists(releasePath))
|
||||
{
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static async Task<int> HoldInstallLeaseAsync(string[] arguments)
|
||||
{
|
||||
if (arguments.Length != 3)
|
||||
|
|
|
|||
|
|
@ -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,62 @@ 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 async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal()
|
||||
{
|
||||
var supervisors = new BlockingStopSupervisorFactory();
|
||||
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
|
||||
LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||
supervisorFactory: supervisors,
|
||||
updateSessionBarrier: barrier);
|
||||
try
|
||||
{
|
||||
_ = await orchestrator.LaunchAsync(
|
||||
"Local ACE",
|
||||
"testaccount",
|
||||
"+Acdream",
|
||||
LaunchMode.Headless);
|
||||
BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created);
|
||||
|
||||
Task disposal = Task.Run(orchestrator.Dispose);
|
||||
Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.False(disposal.IsCompleted);
|
||||
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
|
||||
|
||||
supervisor.AllowTerminal.Set();
|
||||
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
|
||||
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||
Assert.True(supervisor.Disposed);
|
||||
}
|
||||
finally
|
||||
{
|
||||
supervisors.AllowEveryStop();
|
||||
orchestrator.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
||||
{
|
||||
|
|
@ -527,7 +584,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 +621,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;
|
||||
}
|
||||
|
|
@ -733,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory
|
||||
{
|
||||
public List<BlockingStopSupervisor> Created { get; } = [];
|
||||
|
||||
public ILauncherProcessSupervisor Create()
|
||||
{
|
||||
var supervisor = new BlockingStopSupervisor();
|
||||
Created.Add(supervisor);
|
||||
return supervisor;
|
||||
}
|
||||
|
||||
public void AllowEveryStop()
|
||||
{
|
||||
foreach (BlockingStopSupervisor supervisor in Created)
|
||||
{
|
||||
supervisor.AllowTerminal.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor
|
||||
{
|
||||
public ManualResetEventSlim StopEntered { get; } = new(false);
|
||||
|
||||
public ManualResetEventSlim AllowTerminal { get; } = new(false);
|
||||
|
||||
public LauncherSessionState State { get; private set; } =
|
||||
LauncherSessionState.Starting;
|
||||
|
||||
public int? ExitCode { get; private set; }
|
||||
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
public event EventHandler<LauncherSessionState>? StateChanged;
|
||||
|
||||
public void Start(LauncherProcessSpec spec, string? password)
|
||||
{
|
||||
State = LauncherSessionState.Running;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Stop(TimeSpan timeout)
|
||||
{
|
||||
StopEntered.Set();
|
||||
AllowTerminal.Wait();
|
||||
State = LauncherSessionState.Exited;
|
||||
ExitCode = 0;
|
||||
StateChanged?.Invoke(this, State);
|
||||
}
|
||||
|
||||
public void Dispose() => Disposed = true;
|
||||
}
|
||||
|
||||
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
|
||||
{
|
||||
public List<QueueStatusSource> Created { get; } = [];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,262 @@
|
|||
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)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent()
|
||||
{
|
||||
if (!OperatingSystem.IsLinux())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var store = new ClientVersionStore(_paths);
|
||||
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(installed.Directory!, "INSTALL.JSON"),
|
||||
"must-not-be-hidden");
|
||||
|
||||
ClientVersionResolution resolution = await new ClientVersionStore(_paths)
|
||||
.LoadAndRecoverAsync(_rid);
|
||||
|
||||
Assert.Equal(ClientVersionState.Invalid, resolution.State);
|
||||
Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue()
|
||||
{
|
||||
var store = new ClientVersionStore(_paths);
|
||||
Directory.CreateDirectory(store.AppDirectory);
|
||||
string id = Guid.NewGuid().ToString("N");
|
||||
string nearId = id[..31] + "g";
|
||||
string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id);
|
||||
string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id);
|
||||
string exactDownload = Path.Combine(
|
||||
store.AppDirectory,
|
||||
".client-download-" + id + ".zip");
|
||||
string nearStaging = exactStaging + "-user";
|
||||
string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId);
|
||||
string nearDownload = Path.Combine(
|
||||
store.AppDirectory,
|
||||
".client-download-" + id + ".zip.user");
|
||||
Directory.CreateDirectory(exactStaging);
|
||||
Directory.CreateDirectory(exactCorrupt);
|
||||
Directory.CreateDirectory(nearStaging);
|
||||
Directory.CreateDirectory(nearCorrupt);
|
||||
await File.WriteAllTextAsync(exactDownload, "owned");
|
||||
await File.WriteAllTextAsync(nearDownload, "preserve");
|
||||
|
||||
_ = await store.LoadAndRecoverAsync(_rid);
|
||||
|
||||
Assert.False(Directory.Exists(exactStaging));
|
||||
Assert.False(Directory.Exists(exactCorrupt));
|
||||
Assert.False(File.Exists(exactDownload));
|
||||
Assert.True(Directory.Exists(nearStaging));
|
||||
Assert.True(Directory.Exists(nearCorrupt));
|
||||
Assert.True(File.Exists(nearDownload));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,379 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||
{
|
||||
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());
|
||||
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||
harness.Manager.Barrier.AcquireExclusive();
|
||||
Assert.True(harness.Manager.CleanupOwnedResidueUnderLease(
|
||||
pending: null,
|
||||
harness.Target,
|
||||
lease));
|
||||
|
||||
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.RolledBack, rolledBack.State);
|
||||
Assert.All(rolledBack.Apply!, entry =>
|
||||
{
|
||||
if (entry.HadOriginal)
|
||||
{
|
||||
Assert.Matches("^[0-9a-f]{64}$", entry.PriorSha256!);
|
||||
Assert.NotNull(entry.PriorSize);
|
||||
Assert.NotNull(entry.PriorUnixMode);
|
||||
}
|
||||
});
|
||||
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 PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
byte[] firstArchive = UpdateTestData.CreateZip(
|
||||
[
|
||||
(harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED),
|
||||
("support.dat", "support-v2"u8.ToArray(), 0x81A4),
|
||||
("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4),
|
||||
]);
|
||||
_ = await harness.StageAsync("2.0.0", firstArchive);
|
||||
SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
await harness.Manager.ConfirmAsync(
|
||||
first.TransactionId,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target);
|
||||
string obsoletePath = Path.Combine(harness.Target, "obsolete.dll");
|
||||
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
|
||||
|
||||
byte[] secondArchive = UpdateTestData.CreateZip(
|
||||
[
|
||||
(harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED),
|
||||
("support.dat", "support-v3"u8.ToArray(), 0x81A4),
|
||||
]);
|
||||
_ = await harness.StageAsync("3.0.0", secondArchive);
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
|
||||
Assert.False(File.Exists(obsoletePath));
|
||||
Assert.Contains(
|
||||
applied.Apply!,
|
||||
entry => entry.Path == "obsolete.dll"
|
||||
&& entry.Operation == SelfUpdateApplyOperation.Remove
|
||||
&& entry.HadOriginal);
|
||||
|
||||
SelfUpdatePlan rolledBack = await harness.Manager
|
||||
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||
|
||||
Assert.Equal(SelfUpdatePlanState.RolledBack, rolledBack.State);
|
||||
Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath));
|
||||
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
|
||||
|
||||
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
await harness.Manager.ConfirmAsync(
|
||||
retried.TransactionId,
|
||||
harness.Target,
|
||||
harness.LauncherPath);
|
||||
await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target);
|
||||
|
||||
Assert.False(File.Exists(obsoletePath));
|
||||
string ownership = await File.ReadAllTextAsync(Path.Combine(
|
||||
harness.Target,
|
||||
LauncherSelfUpdateManager.InstallRecordFileName));
|
||||
Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ApplyFailpointAfterCanonicalAtomicReplaceLeavesVerifiedRollbackReceipt()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation =>
|
||||
{
|
||||
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
|
||||
&& string.Equals(
|
||||
observation.Path,
|
||||
harness.LauncherName,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
Assert.True(File.Exists(harness.LauncherPath));
|
||||
throw new InvalidOperationException("failpoint");
|
||||
}
|
||||
});
|
||||
|
||||
InvalidOperationException failure = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => faulting.ApplyPendingAsync(harness.Target));
|
||||
SelfUpdatePlan recovered = Assert.IsType<SelfUpdatePlan>(
|
||||
await harness.Manager.LoadPendingAsync());
|
||||
|
||||
Assert.Equal("failpoint", failure.Message);
|
||||
Assert.Equal(SelfUpdatePlanState.RolledBack, recovered.State);
|
||||
await harness.Manager.VerifyRestoredPriorAsync(harness.Target);
|
||||
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(recovered.TransactionId),
|
||||
harness.LauncherName)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConditionalPriorIntegrityFieldsAreStrictAndFailClosed()
|
||||
{
|
||||
using var harness = new Harness(_root);
|
||||
_ = await harness.StageAsync();
|
||||
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||
SelfUpdateApplyEntry canonical = Assert.Single(
|
||||
applied.Apply!,
|
||||
entry => entry.Path == harness.LauncherName);
|
||||
Assert.True(canonical.HadOriginal);
|
||||
Assert.Matches("^[0-9a-f]{64}$", canonical.PriorSha256!);
|
||||
Assert.NotNull(canonical.PriorSize);
|
||||
Assert.NotNull(canonical.PriorUnixMode);
|
||||
Assert.Matches("^[0-9a-f]{64}$", canonical.ReplacementSha256!);
|
||||
|
||||
JsonObject document = Assert.IsType<JsonObject>(JsonNode.Parse(
|
||||
await File.ReadAllTextAsync(harness.Manager.PendingPlanPath)));
|
||||
JsonArray apply = Assert.IsType<JsonArray>(document["apply"]);
|
||||
JsonObject canonicalNode = Assert.IsType<JsonObject>(apply.Single(node =>
|
||||
string.Equals(
|
||||
node?["path"]?.GetValue<string>(),
|
||||
harness.LauncherName,
|
||||
StringComparison.Ordinal)));
|
||||
canonicalNode["priorSha256"] = null;
|
||||
await File.WriteAllTextAsync(
|
||||
harness.Manager.PendingPlanPath,
|
||||
document.ToJsonString());
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
harness.Manager.LoadPendingAsync());
|
||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||
Assert.True(Directory.Exists(
|
||||
harness.Manager.GetTargetTransactionDirectory(applied)));
|
||||
}
|
||||
|
||||
[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.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||
}
|
||||
|
||||
private sealed class Harness : IDisposable
|
||||
{
|
||||
private readonly LocalHttpFixture _server = new();
|
||||
private readonly HttpClient _http = new();
|
||||
private readonly byte[] _archive;
|
||||
private readonly ReleaseArtifact _artifact;
|
||||
private readonly string _root;
|
||||
|
||||
public Harness(string root)
|
||||
{
|
||||
_root = 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 Task<SelfUpdateStageResult> StageAsync(string version, byte[] archive)
|
||||
{
|
||||
_server.Add("launcher.zip", archive);
|
||||
return Manager.StageAsync(
|
||||
LauncherVersion.Parse(version),
|
||||
Rid,
|
||||
new ReleaseArtifact(
|
||||
_server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength),
|
||||
Target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
public LauncherSelfUpdateManager CreateManagerWithObserver(
|
||||
Action<SelfUpdateApplyObservation> observer) =>
|
||||
new(UpdateTestData.Paths(_root), _http, null, observer);
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_http.Dispose();
|
||||
_server.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,673 @@
|
|||
using System.Diagnostics;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||
|
||||
public sealed class LauncherSelfUpdateProcessTests : IDisposable
|
||||
{
|
||||
private const string FixtureBaseName =
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder";
|
||||
private const string DataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
|
||||
private const string TargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
|
||||
private const string HelperPidEnvironment =
|
||||
"ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
|
||||
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-self-update-process-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task KilledAfterCanonicalReplaceCanInvokeCanonicalAndConvergeAutomatically()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string ready = Path.Combine(_root, "crash.ready");
|
||||
string launched = Path.Combine(_root, "replacement.ready");
|
||||
string helperPidPath = Path.Combine(_root, "helper.pid");
|
||||
Directory.CreateDirectory(_root);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
string oldHash = await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath);
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", prepared.NewArchive);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
|
||||
SelfUpdateStageResult staged = await manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
rid,
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(prepared.NewArchive),
|
||||
prepared.NewArchive.LongLength),
|
||||
target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
|
||||
|
||||
using Process crash = StartFixture(
|
||||
["crash-self-update", data, target, ready, prepared.CanonicalName]);
|
||||
try
|
||||
{
|
||||
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
|
||||
Assert.True(File.Exists(prepared.CanonicalPath));
|
||||
crash.Kill(entireProcessTree: true);
|
||||
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.True(File.Exists(prepared.CanonicalPath));
|
||||
string boundaryHash = await FileIntegrity.ComputeSha256HexAsync(
|
||||
prepared.CanonicalPath);
|
||||
Assert.Contains(boundaryHash, new[] { oldHash, prepared.NewCanonicalHash });
|
||||
|
||||
var environment = new Dictionary<string, string>
|
||||
{
|
||||
[DataEnvironment] = data,
|
||||
[TargetEnvironment] = target,
|
||||
[HelperPidEnvironment] = helperPidPath,
|
||||
};
|
||||
using Process canonical = StartProcess(
|
||||
prepared.CanonicalPath,
|
||||
["canonical-probe", launched],
|
||||
environment);
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
await WaitForFileAsync(launched, process: null, TimeSpan.FromSeconds(30));
|
||||
await WaitUntilAsync(
|
||||
() => !File.Exists(manager.PendingPlanPath),
|
||||
TimeSpan.FromSeconds(30),
|
||||
"The self-update journal did not converge.");
|
||||
|
||||
Assert.Equal(
|
||||
prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(prepared.CanonicalPath));
|
||||
Assert.True(File.Exists(Path.Combine(
|
||||
target,
|
||||
LauncherSelfUpdateManager.InstallRecordFileName)));
|
||||
Assert.False(Directory.Exists(manager.GetTransactionDirectory(
|
||||
plan.TransactionId)));
|
||||
Assert.Empty(Directory.EnumerateDirectories(
|
||||
target,
|
||||
".acdream-self-update-*",
|
||||
SearchOption.TopDirectoryOnly));
|
||||
Assert.Null(await manager.LoadPendingAsync());
|
||||
|
||||
int replacementPid = ParsePid(await File.ReadAllTextAsync(launched));
|
||||
int helperPid = int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
await WaitForProcessExitAsync(replacementPid, TimeSpan.FromSeconds(10));
|
||||
await WaitForProcessExitAsync(helperPid, TimeSpan.FromSeconds(10));
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Assert.True(
|
||||
(File.GetUnixFileMode(prepared.CanonicalPath)
|
||||
& (UnixFileMode.UserExecute
|
||||
| UnixFileMode.GroupExecute
|
||||
| UnixFileMode.OtherExecute)) != 0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!crash.HasExited)
|
||||
{
|
||||
crash.Kill(entireProcessTree: true);
|
||||
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CorruptBackupAfterCanonicalCrashNeverLaunchesAndPreservesEvidence()
|
||||
{
|
||||
CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync();
|
||||
string backupPath = Path.Combine(
|
||||
crashed.Manager.GetTargetTransactionDirectory(crashed.Plan),
|
||||
"backup",
|
||||
crashed.Prepared.CanonicalName);
|
||||
Assert.True(File.Exists(backupPath));
|
||||
await File.WriteAllTextAsync(backupPath, "tampered rollback backup");
|
||||
string tamperedHash = await FileIntegrity.ComputeSha256HexAsync(backupPath);
|
||||
string launched = Path.Combine(_root, "corrupt-backup-launched");
|
||||
string helperPidPath = Path.Combine(_root, "corrupt-backup-helper.pid");
|
||||
|
||||
using Process canonical = StartProcess(
|
||||
crashed.Prepared.CanonicalPath,
|
||||
["canonical-probe", launched],
|
||||
BootstrapEnvironment(crashed, helperPidPath));
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
|
||||
await WaitForProcessExitAsync(
|
||||
int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
TimeSpan.FromSeconds(20));
|
||||
|
||||
Assert.False(File.Exists(launched));
|
||||
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
||||
await crashed.Manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Applying, preserved.State);
|
||||
Assert.Equal(crashed.Plan.TransactionId, preserved.TransactionId);
|
||||
Assert.True(Directory.Exists(
|
||||
crashed.Manager.GetTargetTransactionDirectory(crashed.Plan)));
|
||||
Assert.Equal(tamperedHash, await FileIntegrity.ComputeSha256HexAsync(backupPath));
|
||||
Assert.Equal(
|
||||
crashed.Prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(crashed.Prepared.CanonicalPath));
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
crashed.Manager.VerifyRestoredPriorAsync(crashed.Target));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BackupJunctionOrSymlinkAfterCanonicalCrashCannotMutateOutsideOrLaunch()
|
||||
{
|
||||
CrashedUpdate crashed = await PrepareKilledAfterCanonicalReplaceAsync();
|
||||
string swap = crashed.Manager.GetTargetTransactionDirectory(crashed.Plan);
|
||||
string backup = Path.Combine(swap, "backup");
|
||||
string preservedBackup = Path.Combine(_root, "preserved-backup");
|
||||
string outside = Path.Combine(_root, "outside-backup");
|
||||
Directory.Move(backup, preservedBackup);
|
||||
CopyDirectory(preservedBackup, outside);
|
||||
string outsideCanonical = Path.Combine(
|
||||
outside,
|
||||
crashed.Prepared.CanonicalName);
|
||||
string outsideHash = await FileIntegrity.ComputeSha256HexAsync(outsideCanonical);
|
||||
CreateDirectoryLink(backup, outside);
|
||||
string launched = Path.Combine(_root, "reparse-backup-launched");
|
||||
string helperPidPath = Path.Combine(_root, "reparse-backup-helper.pid");
|
||||
|
||||
try
|
||||
{
|
||||
using Process canonical = StartProcess(
|
||||
crashed.Prepared.CanonicalPath,
|
||||
["canonical-probe", launched],
|
||||
BootstrapEnvironment(crashed, helperPidPath));
|
||||
await canonical.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(20));
|
||||
Assert.Equal(0, canonical.ExitCode);
|
||||
await WaitForFileAsync(helperPidPath, process: null, TimeSpan.FromSeconds(20));
|
||||
await WaitForProcessExitAsync(
|
||||
int.Parse(
|
||||
await File.ReadAllTextAsync(helperPidPath),
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
TimeSpan.FromSeconds(20));
|
||||
|
||||
Assert.False(File.Exists(launched));
|
||||
Assert.True(File.Exists(outsideCanonical));
|
||||
Assert.Equal(
|
||||
outsideHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(outsideCanonical));
|
||||
Assert.True(
|
||||
(File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0);
|
||||
SelfUpdatePlan preserved = Assert.IsType<SelfUpdatePlan>(
|
||||
await crashed.Manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Applying, preserved.State);
|
||||
Assert.Equal(
|
||||
crashed.Prepared.NewCanonicalHash,
|
||||
await FileIntegrity.ComputeSha256HexAsync(
|
||||
crashed.Prepared.CanonicalPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
try
|
||||
{
|
||||
if ((File.GetAttributes(backup) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
Directory.Delete(backup);
|
||||
}
|
||||
}
|
||||
catch (FileNotFoundException)
|
||||
{
|
||||
// The assertion above reports an unexpected missing link.
|
||||
}
|
||||
catch (DirectoryNotFoundException)
|
||||
{
|
||||
// The assertion above reports an unexpected missing link.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ConcurrentStartupCannotDeleteAVisibleSlowStageTransaction()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string resultPath = Path.Combine(_root, "startup.result");
|
||||
Directory.CreateDirectory(target);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string canonicalName = LauncherName(rid);
|
||||
string canonical = Path.Combine(target, canonicalName);
|
||||
await File.WriteAllTextAsync(canonical, "running launcher");
|
||||
byte[] archive = UpdateTestData.LauncherZip(
|
||||
rid,
|
||||
new string('s', 2 * 1024 * 1024));
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add(
|
||||
"slow-launcher.zip",
|
||||
archive,
|
||||
chunkSize: 4096,
|
||||
chunkDelay: TimeSpan.FromMilliseconds(2));
|
||||
var observer = new LauncherSelfUpdateManager(
|
||||
UpdateTestData.Paths(_root),
|
||||
new HttpClient());
|
||||
using Process staging = StartFixture(
|
||||
[
|
||||
"stage-self-update",
|
||||
data,
|
||||
target,
|
||||
"2.0.0",
|
||||
rid,
|
||||
server.UriFor("slow-launcher.zip").AbsoluteUri,
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
]);
|
||||
try
|
||||
{
|
||||
await WaitUntilAsync(
|
||||
() => Directory.Exists(observer.TransactionsDirectory)
|
||||
&& Directory.EnumerateDirectories(observer.TransactionsDirectory).Any(),
|
||||
TimeSpan.FromSeconds(10),
|
||||
"The slow staging transaction did not become visible.",
|
||||
staging);
|
||||
Assert.False(File.Exists(observer.PendingPlanPath));
|
||||
string transaction = Assert.Single(
|
||||
Directory.EnumerateDirectories(observer.TransactionsDirectory));
|
||||
|
||||
using Process startup = StartFixture(
|
||||
["bootstrap-probe", data, target, canonical, resultPath]);
|
||||
await startup.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(0, startup.ExitCode);
|
||||
Assert.Equal("ordinary", await File.ReadAllTextAsync(resultPath));
|
||||
Assert.True(Directory.Exists(transaction));
|
||||
Assert.False(File.Exists(observer.PendingPlanPath));
|
||||
|
||||
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(30));
|
||||
Assert.Equal(0, staging.ExitCode);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(
|
||||
await observer.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, plan.State);
|
||||
Assert.True(Directory.Exists(transaction));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!staging.HasExited)
|
||||
{
|
||||
staging.Kill(entireProcessTree: true);
|
||||
await staging.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HelperDefersWithoutRestartWhenSharedSessionLeaseAppears()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string unexpectedLaunch = Path.Combine(_root, "unexpected-launch");
|
||||
string helperPid = Path.Combine(_root, "helper.pid");
|
||||
Directory.CreateDirectory(target);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
string canonical = Path.Combine(target, LauncherName(rid));
|
||||
await File.WriteAllTextAsync(canonical, "old-launcher");
|
||||
byte[] archive = UpdateTestData.LauncherZip(rid, "new-launcher");
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", archive);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
|
||||
_ = await manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
rid,
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(archive),
|
||||
archive.LongLength),
|
||||
target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
|
||||
using UpdateSessionBarrier.SessionLease session = manager.Barrier.AcquireSession();
|
||||
var environment = new Dictionary<string, string>
|
||||
{
|
||||
[DataEnvironment] = data,
|
||||
[TargetEnvironment] = target,
|
||||
[HelperPidEnvironment] = helperPid,
|
||||
};
|
||||
|
||||
using Process helper = StartFixture(
|
||||
[
|
||||
LauncherSelfUpdateBootstrap.HelperArgument,
|
||||
int.MaxValue.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
target,
|
||||
plan.TransactionId,
|
||||
"canonical-probe",
|
||||
unexpectedLaunch,
|
||||
], environment);
|
||||
await helper.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
|
||||
Assert.Equal(LauncherSelfUpdateBootstrap.DeferredLeaseExitCode, helper.ExitCode);
|
||||
Assert.True(File.Exists(helperPid));
|
||||
Assert.False(File.Exists(unexpectedLaunch));
|
||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(canonical));
|
||||
SelfUpdatePlan deferred = Assert.IsType<SelfUpdatePlan>(
|
||||
await manager.LoadPendingAsync());
|
||||
Assert.Equal(SelfUpdatePlanState.Staged, deferred.State);
|
||||
Assert.Equal(plan.TransactionId, deferred.TransactionId);
|
||||
}
|
||||
|
||||
private PreparedLauncher PrepareLauncherClosure(string target, string rid)
|
||||
{
|
||||
string fixtureDirectory = GetFixtureDirectory();
|
||||
string fixtureAppHost = Path.Combine(
|
||||
fixtureDirectory,
|
||||
FixtureBaseName + (OperatingSystem.IsWindows() ? ".exe" : string.Empty));
|
||||
Assert.True(File.Exists(fixtureAppHost), $"Missing fixture apphost: {fixtureAppHost}");
|
||||
Directory.CreateDirectory(target);
|
||||
string canonicalName = LauncherName(rid);
|
||||
string canonicalPath = Path.Combine(target, canonicalName);
|
||||
var archiveEntries = new List<(string Name, byte[] Content, int? UnixAttributes)>();
|
||||
foreach (string source in Directory.EnumerateFiles(
|
||||
fixtureDirectory,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string sourceName = Path.GetFileName(source);
|
||||
bool isAppHost = PathsEqual(source, fixtureAppHost);
|
||||
string targetName = isAppHost ? canonicalName : sourceName;
|
||||
byte[] oldContent = File.ReadAllBytes(source);
|
||||
byte[] newContent = oldContent;
|
||||
if (isAppHost)
|
||||
{
|
||||
oldContent = [.. oldContent, .. "-old"u8.ToArray()];
|
||||
newContent = [.. newContent, .. "-new"u8.ToArray()];
|
||||
}
|
||||
|
||||
string targetPath = Path.Combine(target, targetName);
|
||||
File.WriteAllBytes(targetPath, oldContent);
|
||||
int unixAttributes = 0x81A4;
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
UnixFileMode mode = File.GetUnixFileMode(source);
|
||||
if (isAppHost)
|
||||
{
|
||||
mode |= UnixFileMode.UserExecute;
|
||||
}
|
||||
|
||||
File.SetUnixFileMode(targetPath, mode);
|
||||
unixAttributes = 0x8000 | (int)mode;
|
||||
}
|
||||
else if (isAppHost)
|
||||
{
|
||||
unixAttributes = 0x81ED;
|
||||
}
|
||||
|
||||
archiveEntries.Add((targetName, newContent, unixAttributes));
|
||||
}
|
||||
|
||||
byte[] archive = UpdateTestData.CreateZip(archiveEntries);
|
||||
byte[] newCanonical = Assert.Single(
|
||||
archiveEntries,
|
||||
entry => entry.Name == canonicalName).Content;
|
||||
return new PreparedLauncher(
|
||||
canonicalName,
|
||||
canonicalPath,
|
||||
archive,
|
||||
UpdateTestData.Sha256(newCanonical));
|
||||
}
|
||||
|
||||
private async Task<CrashedUpdate> PrepareKilledAfterCanonicalReplaceAsync()
|
||||
{
|
||||
string data = Path.Combine(_root, "data");
|
||||
string target = Path.Combine(_root, "launcher");
|
||||
string ready = Path.Combine(_root, "crash.ready");
|
||||
Directory.CreateDirectory(_root);
|
||||
string rid = LauncherRuntimeIdentity.DetectRid();
|
||||
PreparedLauncher prepared = PrepareLauncherClosure(target, rid);
|
||||
using var server = new LocalHttpFixture();
|
||||
server.Add("launcher.zip", prepared.NewArchive);
|
||||
using var http = new HttpClient();
|
||||
var manager = new LauncherSelfUpdateManager(UpdateTestData.Paths(_root), http);
|
||||
_ = await manager.StageAsync(
|
||||
LauncherVersion.Parse("2.0.0"),
|
||||
rid,
|
||||
new ReleaseArtifact(
|
||||
server.UriFor("launcher.zip"),
|
||||
UpdateTestData.Sha256(prepared.NewArchive),
|
||||
prepared.NewArchive.LongLength),
|
||||
target,
|
||||
progress: null,
|
||||
CancellationToken.None);
|
||||
SelfUpdatePlan plan = Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync());
|
||||
using Process crash = StartFixture(
|
||||
["crash-self-update", data, target, ready, prepared.CanonicalName]);
|
||||
await WaitForFileAsync(ready, crash, TimeSpan.FromSeconds(20));
|
||||
crash.Kill(entireProcessTree: true);
|
||||
await crash.WaitForExitAsync().WaitAsync(TimeSpan.FromSeconds(10));
|
||||
Assert.Equal(SelfUpdatePlanState.Applying,
|
||||
Assert.IsType<SelfUpdatePlan>(await manager.LoadPendingAsync()).State);
|
||||
return new CrashedUpdate(data, target, manager, plan, prepared);
|
||||
}
|
||||
|
||||
private static Dictionary<string, string> BootstrapEnvironment(
|
||||
CrashedUpdate crashed,
|
||||
string helperPidPath) => new()
|
||||
{
|
||||
[DataEnvironment] = crashed.Data,
|
||||
[TargetEnvironment] = crashed.Target,
|
||||
[HelperPidEnvironment] = helperPidPath,
|
||||
};
|
||||
|
||||
private static void CopyDirectory(string source, string destination)
|
||||
{
|
||||
Directory.CreateDirectory(destination);
|
||||
foreach (string directory in Directory.EnumerateDirectories(
|
||||
source,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
Directory.CreateDirectory(Path.Combine(
|
||||
destination,
|
||||
Path.GetRelativePath(source, directory)));
|
||||
}
|
||||
|
||||
foreach (string file in Directory.EnumerateFiles(
|
||||
source,
|
||||
"*",
|
||||
SearchOption.AllDirectories))
|
||||
{
|
||||
string target = Path.Combine(destination, Path.GetRelativePath(source, file));
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(target)!);
|
||||
File.Copy(file, target);
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
File.SetUnixFileMode(target, File.GetUnixFileMode(file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateDirectoryLink(string link, string target)
|
||||
{
|
||||
if (!OperatingSystem.IsWindows())
|
||||
{
|
||||
Directory.CreateSymbolicLink(link, target);
|
||||
return;
|
||||
}
|
||||
|
||||
var start = new ProcessStartInfo("cmd.exe")
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
start.ArgumentList.Add("/d");
|
||||
start.ArgumentList.Add("/c");
|
||||
start.ArgumentList.Add("mklink");
|
||||
start.ArgumentList.Add("/J");
|
||||
start.ArgumentList.Add(link);
|
||||
start.ArgumentList.Add(target);
|
||||
using Process process = Process.Start(start)
|
||||
?? throw new InvalidOperationException("Could not create the test junction.");
|
||||
process.WaitForExit();
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Could not create the test junction: "
|
||||
+ process.StandardError.ReadToEnd()
|
||||
+ process.StandardOutput.ReadToEnd());
|
||||
}
|
||||
}
|
||||
|
||||
private static Process StartFixture(
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment = null) =>
|
||||
StartProcess("dotnet", [GetFixtureDllPath(), .. arguments], environment);
|
||||
|
||||
private static Process StartProcess(
|
||||
string executable,
|
||||
IReadOnlyList<string> arguments,
|
||||
IReadOnlyDictionary<string, string>? environment = null)
|
||||
{
|
||||
var start = new ProcessStartInfo(executable)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
RedirectStandardError = true,
|
||||
RedirectStandardOutput = true,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
foreach (string argument in arguments)
|
||||
{
|
||||
start.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
if (environment is not null)
|
||||
{
|
||||
foreach ((string name, string value) in environment)
|
||||
{
|
||||
start.Environment[name] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return Process.Start(start)
|
||||
?? throw new InvalidOperationException($"Could not start '{executable}'.");
|
||||
}
|
||||
|
||||
private static async Task WaitForFileAsync(
|
||||
string path,
|
||||
Process? process,
|
||||
TimeSpan timeout) =>
|
||||
await WaitUntilAsync(
|
||||
() => File.Exists(path),
|
||||
timeout,
|
||||
$"Timed out waiting for '{path}'.",
|
||||
process);
|
||||
|
||||
private static async Task WaitUntilAsync(
|
||||
Func<bool> condition,
|
||||
TimeSpan timeout,
|
||||
string failure,
|
||||
Process? process = null)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout;
|
||||
while (!condition())
|
||||
{
|
||||
if (process?.HasExited == true)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"{failure} Process exited {process.ExitCode}. stdout: "
|
||||
+ await process.StandardOutput.ReadToEndAsync()
|
||||
+ " stderr: "
|
||||
+ await process.StandardError.ReadToEndAsync());
|
||||
}
|
||||
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException(failure);
|
||||
}
|
||||
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
private static int ParsePid(string marker)
|
||||
{
|
||||
string value = marker.Split('|', 2)[0];
|
||||
return int.Parse(value, System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static async Task WaitForProcessExitAsync(int pid, TimeSpan timeout)
|
||||
{
|
||||
try
|
||||
{
|
||||
using Process process = Process.GetProcessById(pid);
|
||||
await process.WaitForExitAsync().WaitAsync(timeout);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// It exited before the test opened the process handle.
|
||||
}
|
||||
}
|
||||
|
||||
private static string LauncherName(string rid) =>
|
||||
"acdream-launcher"
|
||||
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
||||
|
||||
private static string GetFixtureDllPath() =>
|
||||
Path.Combine(GetFixtureDirectory(), FixtureBaseName + ".dll");
|
||||
|
||||
private static string GetFixtureDirectory()
|
||||
{
|
||||
string configuration = new DirectoryInfo(AppContext.BaseDirectory)
|
||||
.Parent?.Name ?? "Release";
|
||||
return Path.Combine(
|
||||
FindRepositoryRoot(),
|
||||
"tests",
|
||||
"AcDream.Launcher.Core.Tests.Fixtures.InstallLeaseHolder",
|
||||
"bin",
|
||||
configuration,
|
||||
"net10.0");
|
||||
}
|
||||
|
||||
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.");
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) => string.Equals(
|
||||
Path.GetFullPath(left),
|
||||
Path.GetFullPath(right),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private sealed record PreparedLauncher(
|
||||
string CanonicalName,
|
||||
string CanonicalPath,
|
||||
byte[] NewArchive,
|
||||
string NewCanonicalHash);
|
||||
|
||||
private sealed record CrashedUpdate(
|
||||
string Data,
|
||||
string Target,
|
||||
LauncherSelfUpdateManager Manager,
|
||||
SelfUpdatePlan Plan,
|
||||
PreparedLauncher Prepared);
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
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 = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
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 = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
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 = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
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 = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
using System.Net;
|
||||
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 = ReleaseManifestClient.CreateLoopbackFixture(
|
||||
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("https://example.test/client", "http://example.test/client"),
|
||||
};
|
||||
|
||||
[Theory]
|
||||
[InlineData("clients", "client")]
|
||||
[InlineData("launchers", "launcher")]
|
||||
public void ProductionManifestRejectsLoopbackHttpArtifacts(
|
||||
string section,
|
||||
string artifact)
|
||||
{
|
||||
string json = ValidJson().Replace(
|
||||
$"https://example.test/{artifact}",
|
||||
$"http://127.0.0.1/{artifact}",
|
||||
StringComparison.Ordinal);
|
||||
|
||||
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
|
||||
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
|
||||
|
||||
Assert.Contains(section, error.Message, StringComparison.Ordinal);
|
||||
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
|
||||
{
|
||||
var handler = new SequenceHandler((request, _) => Redirect(
|
||||
HttpStatusCode.Found,
|
||||
new Uri("http://127.0.0.1/manifest.json")));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
ReleaseManifestClient.ProductionManifestUri,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => source.FetchAsync());
|
||||
|
||||
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
|
||||
{
|
||||
var start = new Uri("https://example.test/start");
|
||||
var handler = new SequenceHandler((request, _) => Redirect(
|
||||
HttpStatusCode.TemporaryRedirect,
|
||||
new Uri("http://example.test/plaintext-hop")));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
start,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
|
||||
|
||||
Assert.Equal([start], handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
|
||||
{
|
||||
var first = new Uri("https://example.test/first");
|
||||
var second = new Uri("https://example.test/second");
|
||||
var handler = new SequenceHandler((request, _) => Redirect(
|
||||
HttpStatusCode.PermanentRedirect,
|
||||
request.RequestUri == first ? second : first));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
first,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => source.FetchAsync());
|
||||
|
||||
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal([first, second], handler.Requests);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
|
||||
{
|
||||
var start = new Uri("https://example.test/hop-0");
|
||||
var handler = new SequenceHandler((_, index) => Redirect(
|
||||
HttpStatusCode.Found,
|
||||
new Uri($"https://example.test/hop-{index + 1}")));
|
||||
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||
start,
|
||||
allowLoopbackHttp: false,
|
||||
handler);
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => source.FetchAsync());
|
||||
|
||||
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal(6, handler.Requests.Count);
|
||||
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
|
||||
}
|
||||
|
||||
private static string ValidJson() =>
|
||||
$$$$"""
|
||||
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
|
||||
""";
|
||||
|
||||
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
|
||||
{
|
||||
var response = new HttpResponseMessage(status);
|
||||
response.Headers.Location = location;
|
||||
return response;
|
||||
}
|
||||
|
||||
private sealed class SequenceHandler(
|
||||
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
|
||||
: HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Uri uri = request.RequestUri
|
||||
?? throw new InvalidOperationException("Test request has no URI.");
|
||||
int index = Requests.Count;
|
||||
Requests.Add(uri);
|
||||
return Task.FromResult(respond(request, index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
|
||||
{
|
||||
var handler = new RedirectHandler();
|
||||
using var http = new HttpClient(handler);
|
||||
var downloader = new VerifiedArtifactDownloader(http);
|
||||
string destination = Path.Combine(_root, "redirect.zip");
|
||||
|
||||
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||
downloader.DownloadAsync(
|
||||
new ReleaseArtifact(
|
||||
new Uri("https://example.test/artifact"),
|
||||
new string('a', 64),
|
||||
12),
|
||||
destination));
|
||||
|
||||
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
|
||||
Assert.False(File.Exists(destination));
|
||||
}
|
||||
|
||||
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
|
||||
: IProgress<ArtifactDownloadProgress>
|
||||
{
|
||||
public void Report(ArtifactDownloadProgress value) => callback(value);
|
||||
}
|
||||
|
||||
private sealed class RedirectHandler : HttpMessageHandler
|
||||
{
|
||||
public List<Uri> Requests { get; } = [];
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Requests.Add(request.RequestUri!);
|
||||
var response = new HttpResponseMessage(HttpStatusCode.Found);
|
||||
response.Headers.Location = new Uri("http://example.test/plaintext");
|
||||
return Task.FromResult(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
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("CLOCK$/value")]
|
||||
[InlineData("CONIN$.txt")]
|
||||
[InlineData("CONOUT$/value")]
|
||||
[InlineData("COM¹.dll")]
|
||||
[InlineData("com²/value")]
|
||||
[InlineData("LPT³.log")]
|
||||
[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")));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("CONIN$.txt")]
|
||||
[InlineData("CONOUT$/child")]
|
||||
[InlineData("COM¹.dll")]
|
||||
[InlineData("LPT³/child")]
|
||||
[InlineData("CLOCK$")]
|
||||
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
|
||||
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
|
||||
|
||||
[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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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.");
|
||||
}
|
||||
}
|
||||
270
tests/AcDream.Launcher.Core.Tests/Updates/UpdateTestSupport.cs
Normal file
270
tests/AcDream.Launcher.Core.Tests/Updates/UpdateTestSupport.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
using System.Text.Json;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
public sealed class LauncherUpdateCompositionTests : IDisposable
|
||||
{
|
||||
private readonly string _root = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
"acdream-launcher-composition-tests",
|
||||
Guid.NewGuid().ToString("N"));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_root))
|
||||
{
|
||||
Directory.Delete(_root, recursive: true);
|
||||
}
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("io")]
|
||||
[InlineData("permission")]
|
||||
[InlineData("corrupt")]
|
||||
public async Task StartupStorageFailureComposesUnavailableUpdaterWithoutThrowing(
|
||||
string failure)
|
||||
{
|
||||
Directory.CreateDirectory(_root);
|
||||
var paths = new ApplicationPathSet(
|
||||
Path.Combine(_root, "config"),
|
||||
Path.Combine(_root, "data"),
|
||||
Path.Combine(_root, "cache"),
|
||||
null);
|
||||
Exception exception = failure switch
|
||||
{
|
||||
"io" => new IOException("storage offline"),
|
||||
"permission" => new UnauthorizedAccessException("storage denied"),
|
||||
"corrupt" => new JsonException("pointer corrupt"),
|
||||
_ => throw new InvalidOperationException("Unknown fixture failure."),
|
||||
};
|
||||
|
||||
using LauncherUpdateComposition composition = LauncherUpdateComposition.Create(
|
||||
paths,
|
||||
LauncherRuntimeIdentity.DetectRid(),
|
||||
LauncherVersion.Parse("1.0.0"),
|
||||
_root,
|
||||
() => false,
|
||||
(_, _) => throw exception);
|
||||
|
||||
Assert.Equal(ClientVersionState.Invalid, composition.Updater.CurrentClient.State);
|
||||
Assert.Contains(
|
||||
exception.Message,
|
||||
composition.Updater.CurrentClient.Status,
|
||||
StringComparison.Ordinal);
|
||||
LauncherCapability capability = composition.Executables.GetAvailability(LaunchMode.Gui);
|
||||
Assert.False(capability.IsAvailable);
|
||||
Assert.Contains(exception.Message, capability.Reason, StringComparison.Ordinal);
|
||||
LauncherUpdateException updateError = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||
() => composition.Updater.CheckAsync());
|
||||
Assert.Contains(exception.Message, updateError.Message, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
292
tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
Normal file
292
tests/AcDream.Launcher.Tests/LauncherUpdateViewModelTests.cs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
|
||||
namespace AcDream.Launcher.Tests;
|
||||
|
||||
public sealed class LauncherUpdateViewModelTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task StartupPollingIsOfflineTolerantAndDoesNotOpenErrorModal()
|
||||
{
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
CheckHandler = _ => Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("fixture offline")),
|
||||
};
|
||||
using var viewModel = Create(updater);
|
||||
|
||||
await viewModel.StartupCheckAsync();
|
||||
|
||||
Assert.False(viewModel.IsOpen);
|
||||
Assert.False(viewModel.HasError);
|
||||
Assert.Contains("continuing offline", viewModel.Status, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Equal(LauncherUpdatePhase.Failed, viewModel.Phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartupUpdateOpensModalAndClientInstallProjectsProgressAndRefreshesVersions()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
int changed = 0;
|
||||
using var viewModel = Create(updater, () => changed++);
|
||||
|
||||
await viewModel.StartupCheckAsync();
|
||||
|
||||
Assert.True(viewModel.IsOpen);
|
||||
Assert.Equal("2.0.0", viewModel.AvailableVersion);
|
||||
Assert.Equal("1.0.0", viewModel.CurrentClientVersion);
|
||||
Assert.True(viewModel.InstallClientCommand.CanExecute(null));
|
||||
await viewModel.InstallClientCommand.ExecuteAsync();
|
||||
|
||||
Assert.Equal(1, updater.InstallCalls);
|
||||
Assert.Equal(1, changed);
|
||||
Assert.Equal("2.0.0", viewModel.CurrentClientVersion);
|
||||
Assert.False(viewModel.IsClientUpdateAvailable);
|
||||
Assert.Equal(100, viewModel.ProgressPercent);
|
||||
Assert.False(viewModel.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ManualCheckShowsErrorsAndCanRetrySuccessfully()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
int calls = 0;
|
||||
updater.CheckHandler = _ => ++calls == 1
|
||||
? Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("malformed fixture manifest"))
|
||||
: Task.FromResult(updater.CreateCheck());
|
||||
using var viewModel = Create(updater);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Assert.True(viewModel.IsOpen);
|
||||
Assert.True(viewModel.HasError);
|
||||
Assert.Contains("malformed", viewModel.Error, StringComparison.Ordinal);
|
||||
await viewModel.CheckCommand.ExecuteAsync();
|
||||
Assert.False(viewModel.HasError);
|
||||
Assert.Equal(2, calls);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MinimumLauncherGateDisablesClientButAllowsVerifiedSelfUpdateStage()
|
||||
{
|
||||
var updater = new FakeUpdater
|
||||
{
|
||||
MinimumSatisfied = false,
|
||||
};
|
||||
using var viewModel = Create(updater);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Assert.True(viewModel.IsLauncherMinimumBlocked);
|
||||
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
|
||||
Assert.True(viewModel.StageLauncherCommand.CanExecute(null));
|
||||
await viewModel.StageLauncherCommand.ExecuteAsync();
|
||||
Assert.Equal(1, updater.StageCalls);
|
||||
Assert.True(viewModel.IsLauncherRestartRequired);
|
||||
Assert.Contains("next start", viewModel.LauncherRestartStatus, StringComparison.Ordinal);
|
||||
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
|
||||
Assert.False(viewModel.HasError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MutationPermissionDisablesInstallStageAndRollbackWhileSessionsRun()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
using var viewModel = Create(updater, canMutate: () => false);
|
||||
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Assert.False(viewModel.InstallClientCommand.CanExecute(null));
|
||||
Assert.False(viewModel.StageLauncherCommand.CanExecute(null));
|
||||
Assert.False(viewModel.RollbackCommand.CanExecute(null));
|
||||
Assert.True(viewModel.CheckCommand.CanExecute(null));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancellationAndRollbackHaveExplicitSafeTerminalStates()
|
||||
{
|
||||
var updater = new FakeUpdater();
|
||||
updater.InstallHandler = async (progress, token) =>
|
||||
{
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.DownloadingClient,
|
||||
"downloading",
|
||||
1,
|
||||
100));
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, token);
|
||||
return updater.CurrentClient;
|
||||
};
|
||||
int changed = 0;
|
||||
using var viewModel = Create(updater, () => changed++);
|
||||
await viewModel.OpenCommand.ExecuteAsync();
|
||||
|
||||
Task install = viewModel.InstallClientCommand.ExecuteAsync();
|
||||
await WaitUntilAsync(() => viewModel.IsBusy);
|
||||
Assert.True(viewModel.CancelCommand.CanExecute(null));
|
||||
viewModel.CancelCommand.Execute(null);
|
||||
await install;
|
||||
|
||||
Assert.Equal(LauncherUpdatePhase.Cancelled, viewModel.Phase);
|
||||
Assert.Contains("cancelled", viewModel.Status, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.False(viewModel.HasError);
|
||||
|
||||
await viewModel.RollbackCommand.ExecuteAsync();
|
||||
Assert.Equal(1, updater.RollbackCalls);
|
||||
Assert.Equal("0.9.0", viewModel.CurrentClientVersion);
|
||||
Assert.Equal(1, changed);
|
||||
}
|
||||
|
||||
private static LauncherUpdateViewModel Create(
|
||||
FakeUpdater updater,
|
||||
Action? changed = null,
|
||||
Func<bool>? canMutate = null) => new(
|
||||
updater,
|
||||
new ImmediateUiDispatcher(),
|
||||
changed ?? (() => { }),
|
||||
canOpen: () => true,
|
||||
canMutate: canMutate ?? (() => true));
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> condition)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + TimeSpan.FromSeconds(5);
|
||||
while (!condition())
|
||||
{
|
||||
if (DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
throw new TimeoutException("View model did not enter the expected state.");
|
||||
}
|
||||
|
||||
await Task.Delay(10);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeUpdater : ILauncherUpdater
|
||||
{
|
||||
private static readonly LauncherVersion One = LauncherVersion.Parse("1.0.0");
|
||||
private static readonly LauncherVersion Two = LauncherVersion.Parse("2.0.0");
|
||||
private static readonly LauncherVersion NineTenths = LauncherVersion.Parse("0.9.0");
|
||||
|
||||
public FakeUpdater()
|
||||
{
|
||||
CurrentClient = Resolution(One, "0.9.0");
|
||||
}
|
||||
|
||||
public ClientVersionResolution CurrentClient { get; private set; }
|
||||
|
||||
public bool MinimumSatisfied { get; init; } = true;
|
||||
|
||||
public int InstallCalls { get; private set; }
|
||||
|
||||
public int StageCalls { get; private set; }
|
||||
|
||||
public int RollbackCalls { get; private set; }
|
||||
|
||||
public Func<CancellationToken, Task<LauncherUpdateCheckResult>>? CheckHandler
|
||||
{
|
||||
get;
|
||||
set;
|
||||
}
|
||||
|
||||
public Func<
|
||||
IProgress<LauncherUpdateProgress>?,
|
||||
CancellationToken,
|
||||
Task<ClientVersionResolution>>? InstallHandler { get; set; }
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default) => Task.FromResult(CurrentClient);
|
||||
|
||||
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
CheckHandler?.Invoke(cancellationToken) ?? Task.FromResult(CreateCheck());
|
||||
|
||||
public async Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
InstallCalls++;
|
||||
if (InstallHandler is not null)
|
||||
{
|
||||
return await InstallHandler(progress, cancellationToken);
|
||||
}
|
||||
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.DownloadingClient,
|
||||
"Downloading fixture.",
|
||||
5,
|
||||
10));
|
||||
CurrentClient = Resolution(Two, "1.0.0");
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.Completed,
|
||||
"Installed fixture.",
|
||||
1,
|
||||
1));
|
||||
return CurrentClient;
|
||||
}
|
||||
|
||||
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
StageCalls++;
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.StagingLauncher,
|
||||
"Staged fixture.",
|
||||
1,
|
||||
1));
|
||||
return Task.FromResult(new SelfUpdateStageResult(
|
||||
Two,
|
||||
"pending.json",
|
||||
"Launcher staged for next start."));
|
||||
}
|
||||
|
||||
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RollbackCalls++;
|
||||
CurrentClient = Resolution(NineTenths, "1.0.0");
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
LauncherUpdatePhase.Completed,
|
||||
"Rolled back fixture.",
|
||||
1,
|
||||
1));
|
||||
return Task.FromResult(CurrentClient);
|
||||
}
|
||||
|
||||
public LauncherUpdateCheckResult CreateCheck()
|
||||
{
|
||||
bool available = CurrentClient.Version! < Two;
|
||||
var artifact = new ReleaseArtifact(
|
||||
new Uri("https://example.test/release.zip"),
|
||||
new string('a', 64),
|
||||
100);
|
||||
var manifest = new ReleaseManifest(
|
||||
Two,
|
||||
MinimumSatisfied ? One : Two,
|
||||
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact },
|
||||
new Dictionary<string, ReleaseArtifact> { ["win-x64"] = artifact });
|
||||
return new LauncherUpdateCheckResult(
|
||||
manifest,
|
||||
"win-x64",
|
||||
One,
|
||||
CurrentClient.Version,
|
||||
available,
|
||||
true,
|
||||
MinimumSatisfied,
|
||||
available ? "Fixture update available." : "Fixture is current.");
|
||||
}
|
||||
|
||||
private static ClientVersionResolution Resolution(
|
||||
LauncherVersion version,
|
||||
string? previous) => new(
|
||||
ClientVersionState.Verified,
|
||||
"Fixture client verified.",
|
||||
version,
|
||||
Path.Combine("fixture", version.Value),
|
||||
previous,
|
||||
null);
|
||||
}
|
||||
}
|
||||
|
|
@ -34,7 +34,7 @@ public sealed class LauncherWindowViewModelTests
|
|||
|
||||
Assert.True(viewModel.IsFirstRunRequired);
|
||||
Assert.Contains("SHA-256", viewModel.FirstRunWizardShell.Body, StringComparison.Ordinal);
|
||||
Assert.Contains("LA10", viewModel.UpdatePromptShell.Body, StringComparison.Ordinal);
|
||||
Assert.Contains("pinned eriknihlen/acdream", viewModel.UpdatePrompt.Body, StringComparison.Ordinal);
|
||||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.FirstRunWizardShell.IsOpen);
|
||||
viewModel.FirstRunWizardShell.CloseCommand.Execute(null);
|
||||
|
|
@ -306,7 +306,7 @@ public sealed class LauncherWindowViewModelTests
|
|||
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
|
||||
Assert.True(viewModel.IsModalOpen);
|
||||
Assert.False(viewModel.AddServerCommand.CanExecute(null));
|
||||
Assert.False(viewModel.UpdatePromptShell.OpenCommand.CanExecute(null));
|
||||
Assert.False(viewModel.UpdatePrompt.OpenCommand.CanExecute(null));
|
||||
Assert.False(Assert.Single(viewModel.Sessions).StopCommand.CanExecute(null));
|
||||
|
||||
// ICommand.Execute cannot bypass the modal gate.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue