fix(launcher): harden updater crash recovery

This commit is contained in:
Erik 2026-08-14 23:12:15 +02:00
parent 2d2a5b5046
commit 1955ca8ab5
27 changed files with 2714 additions and 544 deletions

View file

@ -471,7 +471,8 @@ gate (user): clean-profile first-run against real DATs.
verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json` verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json`
pointer swap, refuse while any session runs, keep previous version for pointer swap, refuse while any session runs, keep previous version for
one-step rollback. one-step rollback.
- Launcher self-update: staged download + rename-dance on next start. - Launcher self-update: staged download + target-local atomic replacement on
next start.
- Session-config composition targets `app/current`'s binaries. - Session-config composition targets `app/current`'s binaries.
**Acceptance:** manifest/download/verify/swap tests against a local HTTP **Acceptance:** manifest/download/verify/swap tests against a local HTTP
@ -488,8 +489,11 @@ before doing network, extraction, or activation work.
The production feed is pinned to GitHub owner/repository The production feed is pinned to GitHub owner/repository
`eriknihlen/acdream`; the launcher reads `eriknihlen/acdream`; the launcher reads
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`. `https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
Tests may inject a loopback HTTP URI, but production artifacts and redirects Tests use a separate internal fixture constructor that may admit loopback HTTP;
must use HTTPS. `manifest.json` is: that allowance never propagates to the production feed. Production manifest
and artifact URIs use HTTPS. Automatic redirects are disabled and every
redirect hop is validated before it is requested; redirect loops, a chain over
five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is:
```json ```json
{ {
@ -557,14 +561,18 @@ same-volume directory rename.
write-through temporary-file + same-directory atomic rename. The last valid write-through temporary-file + same-directory atomic rename. The last valid
pointer is also atomically preserved as `current.previous.json`; startup may pointer is also atomically preserved as `current.previous.json`; startup may
restore that exact backup only when `current.json` is missing/malformed and restore that exact backup only when `current.json` is missing/malformed and
the referenced version verifies. Orphan LA10 staging directories and pointer the referenced version verifies. Orphan LA10 staging directories, download
temporaries are transaction-owned by exact names and are removed under the archives, corrupt-version quarantine directories, and pointer temporaries are
update lease. A corrupt installed version is never silently selected; the transaction-owned by exact lowercase GUID names and are removed only under the
explicit one-step rollback swaps the two verified pointer versions. exclusive update lease; near-matching user names are preserved. A corrupt
installed version is never silently selected; the explicit one-step rollback
swaps the two verified pointer versions.
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each `DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
supervised launcher activity holds a shared OS handle from before executable supervised launcher activity holds a shared OS handle from before executable
resolution until terminal process observation; an update/rollback holds the resolution until terminal process observation; launcher disposal requests
child termination and does not release that handle until the child is actually
observed terminal. An update/rollback holds the
exclusive handle for its entire recovery/download/extract/promote/pointer exclusive handle for its entire recovery/download/extract/promote/pointer
transaction. Failure to acquire the exclusive handle is an immediate refusal, transaction. Failure to acquire the exclusive handle is an immediate refusal,
not a wait behind a running session. The open handle, not lock-file contents, not a wait behind a running session. The open handle, not lock-file contents,
@ -572,11 +580,11 @@ owns the lease and therefore releases after process death.
Launcher self-update staging lives at Launcher self-update staging lives at
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole `DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
durable authority is `DataDirectory/launcher-update/pending.json`: durable authority is `DataDirectory/launcher-update/pending.json` (schema 2):
```json ```json
{ {
"schemaVersion": 1, "schemaVersion": 2,
"transactionId": "0123456789abcdef0123456789abcdef", "transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged", "state": "staged",
"version": "1.2.3", "version": "1.2.3",
@ -591,17 +599,62 @@ durable authority is `DataDirectory/launcher-update/pending.json`:
} }
``` ```
Before mutation a next-start helper copied outside the target directory Before mutation the verified staged launcher becomes the next-start helper and
atomically advances the plan to `applying` and fills `apply` with each path's waits for the initiating launcher PID without invoking a shell. It first copies
`hadOriginal` bit. It waits for the initiating launcher PID without invoking a the complete verified payload into the target-local
shell, moves originals into the transaction backup tree, then moves verified `.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
staged files into place. It never opens a target with truncate/overwrite. On to `applying`; `apply` is an ordinally sorted union of new payload paths, the
success the plan becomes `awaitingConfirmation`; the new launcher confirms at owned metadata path, and obsolete paths from the previous ownership record:
its first managed instruction, after which backup and plan cleanup is safe. An
```json
[
{ "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true },
{ "path": "obsolete.dll", "operation": "remove", "hadOriginal": true }
]
```
Existing targets are replaced with one same-filesystem atomic replace whose
backup is also target-local. Previously absent noncanonical files use one
same-filesystem rename; obsolete owned files use one rename into backup. The
canonical launcher path therefore contains either the complete old file or the
complete new file at every durable crash boundary. Rollback reverses the same
operations atomically and is idempotent after a process/power loss. Linux mode
bits come from the verified incoming file. A helper that cannot immediately
acquire the exclusive update lease defers the staged plan and exits without
restarting the old launcher, preventing restart loops.
Successful application writes strict target ownership metadata at
`<launcher directory>/launcher.install.json`:
```json
{
"schemaVersion": 1,
"version": "1.2.3",
"rid": "win-x64",
"files": [
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
]
}
```
The archive may not supply that reserved metadata path. A prior valid record is
the only authority for obsolete-file removal; the first managed update does
not infer ownership of unrelated legacy files. On success the plan becomes
`awaitingConfirmation`; the new launcher confirms at its first managed
instruction, after which the helper releases its lease and the confirmed
launcher reclaims plan, data-transaction, and target-local residue. An
`applying` plan is rolled back before retry, and failure to start/confirm the `applying` plan is rolled back before retry, and failure to start/confirm the
new launcher restores every original (and removes every no-original target). new launcher restores every original (and removes every no-original target).
All plan paths are re-derived/contained under the pinned data root except the Reading `pending.json` never performs cleanup. Ordinary startup attempts the
target directory, which must equal the actual launcher base directory. exclusive lease without waiting and skips update cleanup entirely when another
session/staging transaction owns it. All plan paths are re-derived/contained
under pinned roots; the target directory must equal the actual launcher base
directory.
Every portable archive and persisted relative path rejects Windows device
segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`,
`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript
forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
## LA11 — closeout ## LA11 — closeout

View file

@ -298,19 +298,19 @@ preview would be a deliberate divergence we are NOT taking.
install to `DataDirectory/app/<version>/`; atomic pointer swap install to `DataDirectory/app/<version>/`; atomic pointer swap
(`current.json`); never while any session is running; keep the previous (`current.json`); never while any session is running; keep the previous
version for one-step rollback. version for one-step rollback.
- **Launcher self-update:** same feed; staged download; rename-dance swap - **Launcher self-update:** same feed; staged download; target-local atomic
on next start (a running exe can't replace itself on Windows). replacement on next start after the running process exits.
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips - **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
are release assets; the launcher pins the repo/owner in its config. are release assets; the launcher pins the repo/owner in its config.
The exact v1 manifest, extracted-version record, `current.json` activation The exact v1 manifest, extracted-version record, `current.json` activation
pointer, shared-session/exclusive-update OS lease, and durable self-update pointer and launcher ownership record, shared-session/exclusive-update OS
plan are pinned in lease, and durable self-update plan schema 2 are pinned in
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater `docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
contracts (v1, BINDING)**. That section is normative: implementations reject contracts (v1, BINDING)**. That section is normative: implementations reject
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0 unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
precedence, verify bounded streamed downloads before safe ZIP extraction, and precedence, verify bounded streamed downloads before safe ZIP extraction, and
derive all mutable staging/backup paths from the application data root. The use per-hop redirect validation plus same-filesystem atomic replacement. The
LA9 DAT/pak install record remains the sole content descriptor fed to session LA9 DAT/pak install record remains the sole content descriptor fed to session
configs; LA10 changes only which verified `app/current.json` client binaries configs; LA10 changes only which verified `app/current.json` client binaries
the process supervisor executes. the process supervisor executes.

View file

@ -44,6 +44,7 @@ public sealed class LauncherProcessSupervisorFactory(
/// </summary> /// </summary>
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
{ {
private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5);
private readonly ILauncherChildProcessFactory _factory; private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new(); private readonly object _gate = new();
private readonly Queue<LauncherSessionState> _pendingStateChanges = []; private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
private LauncherSessionState _state = LauncherSessionState.Starting; private LauncherSessionState _state = LauncherSessionState.Starting;
private int? _exitCode; private int? _exitCode;
private bool _publishingStateChanges; private bool _publishingStateChanges;
private bool _disposed;
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null) public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
{ {
@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
ILauncherChildProcess process; ILauncherChildProcess process;
lock (_gate) lock (_gate)
{ {
ObjectDisposedException.ThrowIf(_disposed, this);
if (_process is not null) if (_process is not null)
{ {
throw new InvalidOperationException( throw new InvalidOperationException(
@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
if (!process.WaitForExit(timeout) && !process.HasExited) if (!process.WaitForExit(timeout) && !process.HasExited)
{ {
process.Kill(); process.Kill();
if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited)
{
throw new InvalidOperationException(
"The launcher child could not be observed terminal after it was killed.");
}
} }
} }
@ -307,6 +315,23 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
public void Dispose() public void Dispose()
{ {
ILauncherChildProcess? process;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
process = _process;
}
if (process is { HasExited: false })
{
Stop(DisposeStopTimeout);
}
lock (_gate) lock (_gate)
{ {
if (_process is not null) if (_process is not null)

View file

@ -149,6 +149,13 @@ public sealed class LauncherExecutableSet
}); });
} }
public static LauncherExecutableSet Unavailable(string reason)
{
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
return new LauncherExecutableSet(
() => throw new LauncherUpdateException(reason));
}
private ExecutablePaths RequireAvailable(LaunchMode mode) private ExecutablePaths RequireAvailable(LaunchMode mode)
{ {
LauncherCapability capability = GetAvailability(mode); LauncherCapability capability = GetAvailability(mode);

View file

@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
} }
request.Cancellation.Dispose(); request.Cancellation.Dispose();
request.Activity.StartCompleted.Set();
} }
} }
@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private static void DisposeActivity(ManagedActivity activity) private static void DisposeActivity(ManagedActivity activity)
{ {
activity.StartCancellation?.Cancel(); activity.StartCancellation?.Cancel();
activity.StartCompleted.Wait();
activity.StartCancellation?.Dispose(); activity.StartCancellation?.Dispose();
activity.StartCancellation = null; activity.StartCancellation = null;
if (activity.Supervisor is not null) if (activity.Supervisor is not null)
{ {
// Disposal is a process-lifetime transaction: the shared update
// lease remains held until Stop has observed the real child
// terminal (including the post-kill wait).
activity.Supervisor.Stop(TimeSpan.FromSeconds(5));
if (activity.SupervisorStateHandler is not null) if (activity.SupervisorStateHandler is not null)
{ {
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler; activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
} }
ReleaseUpdateSessionLease(activity); ReleaseUpdateSessionLease(activity);
activity.StartCompleted.Dispose();
} }
private static void ReleaseUpdateSessionLease(ManagedActivity activity) private static void ReleaseUpdateSessionLease(ManagedActivity activity)
@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public UpdateSessionBarrier.SessionLease? UpdateSessionLease; public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
public ManualResetEventSlim StartCompleted { get; } = new(false);
public object StatusReadGate { get; } = new(); public object StatusReadGate { get; } = new();
public bool IsActive => State is not ( public bool IsActive => State is not (

View file

@ -503,7 +503,9 @@ public sealed class ClientVersionStore
.Where(path => !string.Equals( .Where(path => !string.Equals(
path, path,
"install.json", "install.json",
StringComparison.OrdinalIgnoreCase)) OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
.OrderBy(path => path, StringComparer.Ordinal) .OrderBy(path => path, StringComparer.Ordinal)
.ToArray(); .ToArray();
string[] recordedFiles = record.Files string[] recordedFiles = record.Files
@ -809,13 +811,43 @@ public sealed class ClientVersionStore
".client-staging-*", ".client-staging-*",
SearchOption.TopDirectoryOnly)) SearchOption.TopDirectoryOnly))
{ {
string suffix = Path.GetFileName(path)[".client-staging-".Length..]; if (HasCanonicalGuidName(
if (Guid.TryParseExact(suffix, "N", out _)) Path.GetFileName(path),
".client-staging-",
string.Empty))
{ {
SafeZipExtractor.TryDeleteDirectory(path); SafeZipExtractor.TryDeleteDirectory(path);
} }
} }
foreach (string path in Directory.EnumerateDirectories(
AppDirectory,
".client-corrupt-*",
SearchOption.TopDirectoryOnly))
{
if (HasCanonicalGuidName(
Path.GetFileName(path),
".client-corrupt-",
string.Empty))
{
SafeZipExtractor.TryDeleteDirectory(path);
}
}
foreach (string path in Directory.EnumerateFiles(
AppDirectory,
".client-download-*.zip",
SearchOption.TopDirectoryOnly))
{
if (HasCanonicalGuidName(
Path.GetFileName(path),
".client-download-",
".zip"))
{
VerifiedArtifactDownloader.TryDelete(path);
}
}
foreach (string path in Directory.EnumerateFiles( foreach (string path in Directory.EnumerateFiles(
AppDirectory, AppDirectory,
".current*.tmp", ".current*.tmp",
@ -825,20 +857,43 @@ public sealed class ClientVersionStore
string[] parts = fileName.Split('.'); string[] parts = fileName.Split('.');
if (parts.Length >= 4 if (parts.Length >= 4
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal) && string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
&& Guid.TryParseExact(parts[^2], "N", out _)) && Guid.TryParseExact(parts[^2], "N", out Guid parsed)
&& string.Equals(
parsed.ToString("N"),
parts[^2],
StringComparison.Ordinal))
{ {
VerifiedArtifactDownloader.TryDelete(path); VerifiedArtifactDownloader.TryDelete(path);
} }
} }
} }
private static bool HasCanonicalGuidName(
string fileName,
string prefix,
string suffix)
{
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|| !fileName.EndsWith(suffix, StringComparison.Ordinal)
|| fileName.Length != prefix.Length + 32 + suffix.Length)
{
return false;
}
string value = fileName.Substring(prefix.Length, 32);
return Guid.TryParseExact(value, "N", out Guid parsed)
&& string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal);
}
private void RequireOwnedStagingPath(string path) private void RequireOwnedStagingPath(string path)
{ {
string parent = Path.GetDirectoryName(path) ?? string.Empty; string parent = Path.GetDirectoryName(path) ?? string.Empty;
string fileName = Path.GetFileName(path); string fileName = Path.GetFileName(path);
if (!PathsEqual(parent, AppDirectory) if (!PathsEqual(parent, AppDirectory)
|| !fileName.StartsWith(".client-staging-", StringComparison.Ordinal) || !HasCanonicalGuidName(
|| !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _)) fileName,
".client-staging-",
string.Empty))
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(
"The client extraction path is not an owned LA10 staging directory."); "The client extraction path is not an owned LA10 staging directory.");
@ -901,7 +956,7 @@ public sealed class ClientVersionStore
&& !part.Any(character => && !part.Any(character =>
char.IsControl(character) char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*') || character is '<' or '>' or '"' or '|' or '?' or '*')
&& !IsWindowsDeviceName(part)); && !PortablePathRules.IsWindowsDeviceName(part));
} }
internal static string ResolveContained(string root, string relative) internal static string ResolveContained(string root, string relative)
@ -929,19 +984,6 @@ public sealed class ClientVersionStore
return path; return path;
} }
private static bool IsWindowsDeviceName(string segment)
{
string stem = segment.Split('.')[0];
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| (stem.Length == 4
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
&& stem[3] is >= '1' and <= '9');
}
private static bool PathsEqual(string left, string right) => private static bool PathsEqual(string left, string right) =>
string.Equals( string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)), Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),

View file

@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult(
string[] RemainingArguments); string[] RemainingArguments);
/// <summary> /// <summary>
/// Process-level rename dance for launcher self-update. Every child argument /// Process-level self-update bootstrap. Every child argument is passed through
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with /// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
/// shell command.
/// </summary> /// </summary>
public static class LauncherSelfUpdateBootstrap public static class LauncherSelfUpdateBootstrap
{ {
public const string HelperArgument = "--acdream-self-update-helper-v1"; public const string HelperArgument = "--acdream-self-update-helper-v1";
public const string ConfirmArgument = "--acdream-self-update-confirm-v1"; public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
internal const int DeferredLeaseExitCode = 73;
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30); private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
public static async Task<SelfUpdateStartupResult> HandleAsync( public static async Task<SelfUpdateStartupResult> HandleAsync(
string[] args, string[] args,
@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap
Path.GetFullPath(launcherBaseDirectory)); Path.GetFullPath(launcherBaseDirectory));
string executable = Path.GetFullPath(currentExecutablePath); string executable = Path.GetFullPath(currentExecutablePath);
if (args.Length > 0
&& string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
{
return new SelfUpdateStartupResult(false, 0, args[1..]);
}
if (args.Length > 0 if (args.Length > 0
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal)) && string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{ {
if (args.Length != 4 if (args.Length < 4
|| !int.TryParse( || !int.TryParse(
args[1], args[1],
System.Globalization.NumberStyles.None, System.Globalization.NumberStyles.None,
@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap
parentPid, parentPid,
args[2], args[2],
args[3], args[3],
args[4..],
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return new SelfUpdateStartupResult(true, exitCode, []); return new SelfUpdateStartupResult(true, exitCode, []);
@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap
if (args.Length > 0 if (args.Length > 0
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal)) && string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
{ {
if (args.Length != 2) if (args.Length < 2)
{ {
return new SelfUpdateStartupResult(true, 64, []); return new SelfUpdateStartupResult(true, 64, []);
} }
@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap
executable, executable,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, []); await FinishConfirmedCleanupAsync(
} manager,
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (plan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{
if (!manager.IsConfirmed(plan.TransactionId))
{
await manager.ConfirmAsync(
plan.TransactionId,
baseDirectory,
executable,
cancellationToken)
.ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
plan.TransactionId,
baseDirectory, baseDirectory,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, args[2..]);
}
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? startupLease))
{
// A running session or another launcher is staging. Reading the
// plan is safe, but cleanup or starting a competing helper is not.
return new SelfUpdateStartupResult(false, 0, args); return new SelfUpdateStartupResult(false, 0, args);
} }
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
? ".exe" ?? throw new InvalidOperationException("Exclusive startup lease is missing."))
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
"acdream-launcher" + suffix);
if (!PathsEqual(executable, expectedExecutable))
{ {
throw new LauncherUpdateException( SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
"Self-update can start only from the published acdream-launcher executable."); .ConfigureAwait(false);
} _ = manager.CleanupOwnedResidueUnderLease(
plan,
baseDirectory,
lease);
if (plan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
string helperPath = manager.GetHelperPath(plan.TransactionId); if (!PathsEqual(plan.TargetDirectory, baseDirectory))
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!); {
VerifiedArtifactDownloader.TryDelete(helperPath); throw new LauncherUpdateException(
File.Copy(executable, helperPath, overwrite: false); "The pending self-update targets a different launcher directory.");
if (OperatingSystem.IsLinux()) }
{
File.SetUnixFileMode(
helperPath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute);
}
var startInfo = new ProcessStartInfo(helperPath) if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
{ {
UseShellExecute = false, if (!manager.IsConfirmed(plan.TransactionId))
WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId), {
}; await manager.ConfirmAsync(
startInfo.ArgumentList.Add(HelperArgument); plan.TransactionId,
startInfo.ArgumentList.Add( baseDirectory,
Environment.ProcessId.ToString( executable,
System.Globalization.CultureInfo.InvariantCulture)); cancellationToken)
startInfo.ArgumentList.Add(baseDirectory); .ConfigureAwait(false);
startInfo.ArgumentList.Add(plan.TransactionId); }
_ = Process.Start(startInfo)
?? throw new LauncherUpdateException( await manager.CompleteConfirmedAsync(
"The launcher self-update helper could not be started."); plan.TransactionId,
return new SelfUpdateStartupResult(true, 0, []); baseDirectory,
cancellationToken)
.ConfigureAwait(false);
_ = manager.CleanupOwnedResidueUnderLease(
pending: null,
baseDirectory,
lease);
return new SelfUpdateStartupResult(false, 0, args);
}
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
GetLauncherFileName(plan.Rid));
if (!PathsEqual(executable, expectedExecutable))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
string helperPath = manager.GetStagedLauncherPath(plan);
var startInfo = new ProcessStartInfo(helperPath)
{
UseShellExecute = false,
WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId),
};
startInfo.ArgumentList.Add(HelperArgument);
startInfo.ArgumentList.Add(
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
startInfo.ArgumentList.Add(baseDirectory);
startInfo.ArgumentList.Add(plan.TransactionId);
foreach (string argument in args)
{
startInfo.ArgumentList.Add(argument);
}
_ = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The launcher self-update helper could not be started.");
return new SelfUpdateStartupResult(true, 0, []);
}
} }
private static async Task<int> RunHelperAsync( private static async Task<int> RunHelperAsync(
@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap
int parentPid, int parentPid,
string targetDirectory, string targetDirectory,
string transactionId, string transactionId,
IReadOnlyList<string> publicArguments,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken) SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
@ -164,12 +188,15 @@ public static class LauncherSelfUpdateBootstrap
"The helper transaction does not match the pending self-update."); "The helper transaction does not match the pending self-update.");
} }
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal) if (!PathsEqual(plan.TargetDirectory, targetDirectory))
? ".exe" {
: string.Empty; throw new LauncherUpdateException(
"The helper target does not match the pending self-update.");
}
string launcherPath = ClientVersionStore.ResolveContained( string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory, targetDirectory,
"acdream-launcher" + suffix); GetLauncherFileName(plan.Rid));
var startInfo = new ProcessStartInfo(launcherPath) var startInfo = new ProcessStartInfo(launcherPath)
{ {
UseShellExecute = false, UseShellExecute = false,
@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap
}; };
startInfo.ArgumentList.Add(ConfirmArgument); startInfo.ArgumentList.Add(ConfirmArgument);
startInfo.ArgumentList.Add(transactionId); startInfo.ArgumentList.Add(transactionId);
foreach (string argument in publicArguments)
Process? replacement = null;
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
bool appliedByThisHelper = false;
try
{ {
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false); startInfo.ArgumentList.Add(argument);
updateLease = manager.Barrier.AcquireExclusive();
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
.ConfigureAwait(false);
appliedByThisHelper = true;
replacement = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The updated launcher could not be started.");
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
while (!manager.IsConfirmed(transactionId))
{
cancellationToken.ThrowIfCancellationRequested();
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
{
throw new LauncherUpdateException(
replacement.HasExited
? $"The updated launcher exited with code {replacement.ExitCode} "
+ "before confirming startup."
: "The updated launcher did not confirm startup in time.");
}
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
await manager.CompleteConfirmedAsync(
transactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
return 0;
} }
catch
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
if (!manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? updateLease))
{ {
if (replacement is { HasExited: false }) // Do not restart the canonical launcher: it would immediately see
// the same staged plan and create an unbounded helper loop.
return DeferredLeaseExitCode;
}
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
?? throw new InvalidOperationException("Exclusive update lease is missing."))
{
plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false)
?? throw new LauncherUpdateException(
"The helper found no pending self-update after acquiring the lease.");
if (!string.Equals(
plan.TransactionId,
transactionId,
StringComparison.Ordinal)
|| !PathsEqual(plan.TargetDirectory, targetDirectory))
{ {
replacement.Kill(entireProcessTree: true); throw new LauncherUpdateException(
await replacement.WaitForExitAsync(CancellationToken.None) "The pending self-update changed before the helper acquired its lease.");
.ConfigureAwait(false);
} }
_ = manager.CleanupOwnedResidueUnderLease(
plan,
targetDirectory,
lease);
Process? replacement = null;
bool appliedByThisHelper = false;
try try
{ {
if (appliedByThisHelper) plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
.ConfigureAwait(false);
appliedByThisHelper = true;
replacement = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The updated launcher could not be started.");
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
while (!manager.IsConfirmed(transactionId))
{ {
SelfUpdatePlan? pending = await manager.LoadPendingAsync( cancellationToken.ThrowIfCancellationRequested();
CancellationToken.None) if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
{ {
_ = await manager.RecoverApplyingAsync( throw new LauncherUpdateException(
targetDirectory, replacement.HasExited
CancellationToken.None) ? $"The updated launcher exited with code {replacement.ExitCode} "
.ConfigureAwait(false); + "before confirming startup."
} : "The updated launcher did not confirm startup in time.");
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
} }
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
} }
await manager.CompleteConfirmedAsync(
transactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
return 0;
} }
catch catch
{ {
// Do not start an executable from an ambiguous half-applied if (replacement is { HasExited: false })
// state. A subsequent startup replays the durable journal. {
return 75; replacement.Kill(entireProcessTree: true);
} await replacement.WaitForExitAsync(CancellationToken.None)
.ConfigureAwait(false);
}
var restored = new ProcessStartInfo(launcherPath) try
{
if (appliedByThisHelper)
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
CancellationToken.None)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
{
_ = await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
}
}
catch
{
// An ambiguous state must not start either executable.
return 75;
}
var restored = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
restored.ArgumentList.Add(DeferredArgument);
foreach (string argument in publicArguments)
{
restored.ArgumentList.Add(argument);
}
_ = Process.Start(restored);
return 74;
}
finally
{ {
UseShellExecute = false, replacement?.Dispose();
WorkingDirectory = Path.GetFullPath(targetDirectory), }
};
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
updateLease?.Dispose();
} }
} }
private static async Task FinishConfirmedCleanupAsync(
LauncherSelfUpdateManager manager,
string targetDirectory,
CancellationToken cancellationToken)
{
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
do
{
cancellationToken.ThrowIfCancellationRequested();
if (manager.Barrier.TryAcquireExclusive(
out UpdateSessionBarrier.ExclusiveLease? lease))
{
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
?? throw new InvalidOperationException(
"Exclusive cleanup lease is missing."))
{
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
if (pending is
{
State: SelfUpdatePlanState.AwaitingConfirmation,
}
&& manager.IsConfirmed(pending.TransactionId))
{
await manager.CompleteConfirmedAsync(
pending.TransactionId,
targetDirectory,
cancellationToken)
.ConfigureAwait(false);
pending = null;
}
if (manager.CleanupOwnedResidueUnderLease(
pending,
targetDirectory,
acquiredLease))
{
return;
}
}
}
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
}
while (DateTimeOffset.UtcNow < deadline);
}
private static string GetLauncherFileName(string rid) =>
"acdream-launcher"
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
private static async Task WaitForParentExitAsync( private static async Task WaitForParentExitAsync(
int parentPid, int parentPid,
CancellationToken cancellationToken) CancellationToken cancellationToken)

View file

@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater
try try
{ {
RefuseRunningSessions(); RefuseRunningSessions();
using UpdateSessionBarrier.ExclusiveLease lease =
_versions.Barrier.AcquireExclusive();
RefuseRunningSessions();
if (check.Manifest.Version <= _launcherVersion) if (check.Manifest.Version <= _launcherVersion)
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(

View file

@ -0,0 +1,38 @@
namespace AcDream.Launcher.Core.Updates;
/// <summary>
/// Host-independent path rules for payloads that must remain safe when moved
/// between Linux and Windows. Windows device aliases are rejected on every
/// host so a release cannot verify on one platform and become ambiguous on
/// another.
/// </summary>
internal static class PortablePathRules
{
public static bool IsWindowsDeviceName(string segment)
{
ArgumentNullException.ThrowIfNull(segment);
string stem = segment.Split('.')[0];
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("CONIN$", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase))
{
return true;
}
if (stem.Length != 4
|| (!stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
&& !stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)))
{
return false;
}
return stem[3] is >= '1' and <= '9'
or '\u00b9'
or '\u00b2'
or '\u00b3';
}
}

View file

@ -10,9 +10,11 @@ public interface IReleaseManifestClient
} }
/// <summary> /// <summary>
/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is /// Strict, bounded reader for the pinned GitHub Releases manifest. Production
/// accepted only for a loopback fixture; production and artifact URLs are /// construction is HTTPS-only. The loopback HTTP allowance is available only
/// HTTPS-only. /// through an internal fixture factory and is never inferred from a URI.
/// Redirects are followed manually so every hop is checked before any bytes
/// cross that hop.
/// </summary> /// </summary>
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
{ {
@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
public const string GitHubRepository = "acdream"; public const string GitHubRepository = "acdream";
public const int MaximumManifestBytes = 1024 * 1024; public const int MaximumManifestBytes = 1024 * 1024;
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024; public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
public const int MaximumRedirects = 5;
public static Uri ProductionManifestUri { get; } = new( public static Uri ProductionManifestUri { get; } = new(
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json"); $"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
@ -33,65 +36,103 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
}; };
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly bool _ownsHttpClient;
private readonly Uri _manifestUri; private readonly Uri _manifestUri;
private readonly bool _allowLoopbackHttp;
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null) public ReleaseManifestClient(TimeSpan? timeout = null)
: this(
ProductionManifestUri,
allowLoopbackHttp: false,
CreateRedirectDisabledHandler(),
timeout)
{ {
_httpClient = httpClient ?? new HttpClient();
_ownsHttpClient = httpClient is null;
_manifestUri = manifestUri ?? ProductionManifestUri;
RequireSecureOrLoopback(_manifestUri, "manifest");
if (_ownsHttpClient)
{
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
}
} }
private ReleaseManifestClient(
Uri manifestUri,
bool allowLoopbackHttp,
HttpMessageHandler handler,
TimeSpan? timeout)
{
ArgumentNullException.ThrowIfNull(manifestUri);
ArgumentNullException.ThrowIfNull(handler);
_manifestUri = manifestUri;
_allowLoopbackHttp = allowLoopbackHttp;
RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp);
_httpClient = new HttpClient(handler, disposeHandler: true)
{
Timeout = timeout ?? TimeSpan.FromSeconds(15),
};
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
}
internal static ReleaseManifestClient CreateLoopbackFixture(
Uri manifestUri,
TimeSpan? timeout = null) => new(
manifestUri,
allowLoopbackHttp: true,
CreateRedirectDisabledHandler(),
timeout);
internal static ReleaseManifestClient CreateForTransportTest(
Uri manifestUri,
bool allowLoopbackHttp,
HttpMessageHandler handler) => new(
manifestUri,
allowLoopbackHttp,
handler,
TimeSpan.FromSeconds(15));
public async Task<ReleaseManifest> FetchAsync( public async Task<ReleaseManifest> FetchAsync(
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
try try
{ {
using HttpResponseMessage response = await _httpClient.GetAsync( Uri current = _manifestUri;
_manifestUri, var visited = new HashSet<string>(StringComparer.Ordinal);
HttpCompletionOption.ResponseHeadersRead, for (int redirectCount = 0;;)
cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri;
RequireSecureOrLoopback(finalUri, "manifest redirect");
if (response.Content.Headers.ContentLength is long contentLength
&& contentLength > MaximumManifestBytes)
{ {
throw new LauncherUpdateException( RequireTransport(current, "manifest redirect", _allowLoopbackHttp);
$"The release manifest is larger than {MaximumManifestBytes} bytes."); if (!visited.Add(current.AbsoluteUri))
}
await using Stream input = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
using var output = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
while (true)
{
int read = await input.ReadAsync(buffer, cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
if (output.Length + read > MaximumManifestBytes)
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes."); "The release manifest redirect chain contains a loop.");
} }
output.Write(buffer, 0, read); using var request = new HttpRequestMessage(HttpMethod.Get, current);
} using HttpResponseMessage response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
if (IsRedirect(response.StatusCode))
{
if (redirectCount >= MaximumRedirects)
{
throw new LauncherUpdateException(
$"The release manifest exceeded {MaximumRedirects} redirects.");
}
return Parse(output.ToArray()); Uri? location = response.Headers.Location;
if (location is null)
{
throw new LauncherUpdateException(
"The release manifest redirect has no Location header.");
}
Uri next = location.IsAbsoluteUri
? location
: new Uri(current, location);
RequireTransport(next, "manifest redirect", _allowLoopbackHttp);
current = next;
redirectCount++;
continue;
}
response.EnsureSuccessStatusCode();
return await ReadAndParseAsync(response, cancellationToken)
.ConfigureAwait(false);
}
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
@ -112,7 +153,9 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
} }
} }
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8) internal static ReleaseManifest Parse(
ReadOnlySpan<byte> utf8,
bool allowLoopbackHttpArtifacts = false)
{ {
try try
{ {
@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
RejectDuplicateProperties(document.RootElement, "$" ); RejectDuplicateProperties(document.RootElement, "$" );
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>( ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
SerializerOptions); SerializerOptions);
return Validate(value); return Validate(value, allowLoopbackHttpArtifacts);
} }
catch (LauncherUpdateException) catch (LauncherUpdateException)
{ {
@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
} }
} }
public void Dispose() public void Dispose() => _httpClient.Dispose();
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
internal static void RequireSecureOrLoopback(Uri uri, string description) internal static void RequireTransport(
Uri uri,
string description,
bool allowLoopbackHttp)
{ {
if (!uri.IsAbsoluteUri if (!uri.IsAbsoluteUri
|| (uri.Scheme != Uri.UriSchemeHttps || (uri.Scheme != Uri.UriSchemeHttps
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback))) && !(allowLoopbackHttp
&& uri.Scheme == Uri.UriSchemeHttp
&& uri.IsLoopback)))
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(
$"The {description} URI must use HTTPS (loopback HTTP is test-only)."); $"The {description} URI must use HTTPS"
+ (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : "."));
} }
} }
private static ReleaseManifest Validate(ManifestDocument? document) internal static void RequireSecureOrLoopback(Uri uri, string description) =>
RequireTransport(uri, description, allowLoopbackHttp: true);
private async Task<ReleaseManifest> ReadAndParseAsync(
HttpResponseMessage response,
CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength is long contentLength
&& contentLength > MaximumManifestBytes)
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
}
await using Stream input = await response.Content
.ReadAsStreamAsync(cancellationToken)
.ConfigureAwait(false);
using var output = new MemoryStream();
byte[] buffer = new byte[16 * 1024];
while (true)
{
int read = await input.ReadAsync(buffer, cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
if (output.Length + read > MaximumManifestBytes)
{
throw new LauncherUpdateException(
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
}
output.Write(buffer, 0, read);
}
return Parse(output.ToArray(), _allowLoopbackHttp);
}
private static ReleaseManifest Validate(
ManifestDocument? document,
bool allowLoopbackHttpArtifacts)
{ {
if (document is null) if (document is null)
{ {
@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
throw new LauncherUpdateException( throw new LauncherUpdateException(
"The minimum launcher version cannot exceed the release version."); "The minimum launcher version cannot exceed the release version.");
} }
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts( IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
document.Clients, document.Clients,
"clients"); "clients",
allowLoopbackHttpArtifacts);
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts( IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
document.Launchers, document.Launchers,
"launchers"); "launchers",
allowLoopbackHttpArtifacts);
return new ReleaseManifest(version, minimum, clients, launchers); return new ReleaseManifest(version, minimum, clients, launchers);
} }
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts( private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
Dictionary<string, ArtifactDocument>? artifacts, Dictionary<string, ArtifactDocument>? artifacts,
string field) string field,
bool allowLoopbackHttpArtifacts)
{ {
if (artifacts is null || artifacts.Count == 0) if (artifacts is null || artifacts.Count == 0)
{ {
@ -226,7 +315,10 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
$"Manifest payload '{field}.{rid}' has an invalid URL."); $"Manifest payload '{field}.{rid}' has an invalid URL.");
} }
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact"); RequireTransport(
uri,
$"{field}.{rid} artifact",
allowLoopbackHttpArtifacts);
if (!IsSha256(value.Sha256)) if (!IsSha256(value.Sha256))
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(
@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
internal static bool IsSha256(string? value) => internal static bool IsSha256(string? value) =>
value is { Length: 64 } && value.All(Uri.IsHexDigit); value is { Length: 64 } && value.All(Uri.IsHexDigit);
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
private static HttpMessageHandler CreateRedirectDisabledHandler() =>
new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
};
private static void RejectDuplicateProperties(JsonElement element, string path) private static void RejectDuplicateProperties(JsonElement element, string path)
{ {
if (element.ValueKind == JsonValueKind.Object) if (element.ValueKind == JsonValueKind.Object)

View file

@ -297,7 +297,7 @@ public sealed class SafeZipExtractor
|| segment.Any(character => || segment.Any(character =>
char.IsControl(character) char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*') || character is '<' or '>' or '"' or '|' or '?' or '*')
|| IsWindowsDeviceName(segment)) || PortablePathRules.IsWindowsDeviceName(segment))
{ {
throw new LauncherUpdateException( throw new LauncherUpdateException(
$"ZIP path '{name}' contains an unsafe segment."); $"ZIP path '{name}' contains an unsafe segment.");
@ -396,19 +396,6 @@ public sealed class SafeZipExtractor
} }
} }
private static bool IsWindowsDeviceName(string segment)
{
string stem = segment.Split('.')[0];
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|| (stem.Length == 4
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
&& stem[3] is >= '1' and <= '9');
}
internal static void TryDeleteDirectory(string path) internal static void TryDeleteDirectory(string path)
{ {
try try

View file

@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier
FileShare.None, FileShare.None,
"A launcher session or another update transaction is running. " "A launcher session or another update transaction is running. "
+ "Stop every launcher session before updating."); + "Stop every launcher session before updating.");
return new ExclusiveLease(stream); return new ExclusiveLease(this, stream);
}
/// <summary>
/// Non-blocking startup probe. Contention is an expected "not now"
/// result; permission and path failures remain hard errors.
/// </summary>
public bool TryAcquireExclusive(out ExclusiveLease? lease)
{
Directory.CreateDirectory(
Path.GetDirectoryName(_lockPath)
?? throw new InvalidOperationException(
"The update/session lock path has no parent directory."));
try
{
lease = new ExclusiveLease(
this,
new FileStream(
_lockPath,
FileMode.OpenOrCreate,
FileAccess.ReadWrite,
FileShare.None,
bufferSize: 1,
FileOptions.None));
return true;
}
catch (IOException)
{
lease = null;
return false;
}
catch (UnauthorizedAccessException ex)
{
throw new LauncherUpdateException(
$"The update/session lease could not be opened: {ex.Message}",
ex);
}
}
internal void RequireOwned(ExclusiveLease lease)
{
ArgumentNullException.ThrowIfNull(lease);
if (!lease.IsHeldBy(this))
{
throw new LauncherUpdateException(
"The cleanup operation does not hold this update barrier's exclusive lease.");
}
} }
private FileStream Open(FileShare share, string refusal) private FileStream Open(FileShare share, string refusal)
@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier
public sealed class ExclusiveLease : IDisposable public sealed class ExclusiveLease : IDisposable
{ {
private readonly UpdateSessionBarrier _owner;
private FileStream? _stream; private FileStream? _stream;
internal ExclusiveLease(FileStream stream) => _stream = stream; internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream)
{
_owner = owner;
_stream = stream;
}
internal bool IsHeldBy(UpdateSessionBarrier owner) =>
ReferenceEquals(_owner, owner)
&& Volatile.Read(ref _stream) is not null;
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose(); public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
} }

View file

@ -1,4 +1,5 @@
using System.Buffers; using System.Buffers;
using System.Net;
using System.Security.Cryptography; using System.Security.Cryptography;
namespace AcDream.Launcher.Core.Updates; namespace AcDream.Launcher.Core.Updates;
@ -55,15 +56,11 @@ public sealed class VerifiedArtifactDownloader
bool ownsDestination = false; bool ownsDestination = false;
try try
{ {
using HttpResponseMessage response = await _httpClient.GetAsync( using HttpResponseMessage response = await SendWithValidatedRedirectsAsync(
artifact.Url, artifact.Url,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken) cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
ReleaseManifestClient.RequireSecureOrLoopback(
response.RequestMessage?.RequestUri ?? artifact.Url,
"artifact redirect");
if (response.Content.Headers.ContentLength is long contentLength if (response.Content.Headers.ContentLength is long contentLength
&& contentLength != artifact.Size) && contentLength != artifact.Size)
{ {
@ -183,6 +180,86 @@ public sealed class VerifiedArtifactDownloader
} }
} }
private async Task<HttpResponseMessage> SendWithValidatedRedirectsAsync(
Uri initialUri,
CancellationToken cancellationToken)
{
bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp
&& initialUri.IsLoopback;
Uri current = initialUri;
var visited = new HashSet<string>(StringComparer.Ordinal);
for (int redirectCount = 0;;)
{
ReleaseManifestClient.RequireTransport(
current,
"artifact redirect",
allowLoopbackHttp);
if (!visited.Add(current.AbsoluteUri))
{
throw new LauncherUpdateException(
"The release artifact redirect chain contains a loop.");
}
using var request = new HttpRequestMessage(HttpMethod.Get, current);
HttpResponseMessage response = await _httpClient.SendAsync(
request,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
Uri effectiveUri = response.RequestMessage?.RequestUri ?? current;
if (!Uri.Equals(effectiveUri, current))
{
response.Dispose();
throw new LauncherUpdateException(
"The artifact HTTP transport followed an automatic redirect; "
+ "every redirect must be validated before it is requested.");
}
if (!IsRedirect(response.StatusCode))
{
return response;
}
try
{
if (redirectCount >= ReleaseManifestClient.MaximumRedirects)
{
throw new LauncherUpdateException(
$"The release artifact exceeded "
+ $"{ReleaseManifestClient.MaximumRedirects} redirects.");
}
Uri? location = response.Headers.Location;
if (location is null)
{
throw new LauncherUpdateException(
"The release artifact redirect has no Location header.");
}
Uri next = location.IsAbsoluteUri
? location
: new Uri(current, location);
ReleaseManifestClient.RequireTransport(
next,
"artifact redirect",
allowLoopbackHttp);
current = next;
redirectCount++;
}
finally
{
response.Dispose();
}
}
}
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
HttpStatusCode.MovedPermanently
or HttpStatusCode.Found
or HttpStatusCode.SeeOther
or HttpStatusCode.TemporaryRedirect
or HttpStatusCode.PermanentRedirect;
internal static void TryDelete(string path) internal static void TryDelete(string path)
{ {
try try

View file

@ -14,6 +14,10 @@
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool> <PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="12.1.1" /> <PackageReference Include="Avalonia" Version="12.1.1" />
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" /> <PackageReference Include="Avalonia.Desktop" Version="12.1.1" />

View file

@ -16,8 +16,7 @@ public sealed partial class App : Application
{ {
private LauncherOrchestrator? _orchestrator; private LauncherOrchestrator? _orchestrator;
private LauncherWindowViewModel? _viewModel; private LauncherWindowViewModel? _viewModel;
private HttpClient? _updateHttpClient; private LauncherUpdateComposition? _updateComposition;
private ReleaseManifestClient? _manifestClient;
public override void Initialize() => AvaloniaXamlLoader.Load(this); public override void Initialize() => AvaloniaXamlLoader.Load(this);
@ -52,38 +51,27 @@ public sealed partial class App : Application
$"Client content verification failed: {ex.Message}"); $"Client content verification failed: {ex.Message}");
} }
var clientVersions = new ClientVersionStore(paths); LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
_ = clientVersions.LoadAndRecoverAsync(rid) paths,
.GetAwaiter() rid,
.GetResult(); GetLauncherVersion(),
AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
== true);
_updateComposition = updates;
_orchestrator = new LauncherOrchestrator( _orchestrator = new LauncherOrchestrator(
profiles, profiles,
paths, paths,
LauncherExecutableSet.FromCurrentVersionStore(clientVersions), updates.Executables,
verification.Record, verification.Record,
installationStatus: verification.Status, installationStatus: verification.Status,
updateSessionBarrier: clientVersions.Barrier); updateSessionBarrier: updates.Versions.Barrier);
_updateHttpClient = new HttpClient();
_updateHttpClient.Timeout = TimeSpan.FromSeconds(15);
_updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
"acdream-launcher/1");
_manifestClient = new ReleaseManifestClient(_updateHttpClient);
var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient);
var updater = new LauncherUpdater(
_manifestClient,
_updateHttpClient,
clientVersions,
selfUpdates,
GetLauncherVersion(),
rid,
AppContext.BaseDirectory,
() => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive));
_viewModel = new LauncherWindowViewModel( _viewModel = new LauncherWindowViewModel(
_orchestrator, _orchestrator,
new AvaloniaUiDispatcher(), new AvaloniaUiDispatcher(),
installer, installer,
updater); updates.Updater);
_viewModel.Initialize(); _viewModel.Initialize();
desktop.MainWindow = new MainWindow desktop.MainWindow = new MainWindow
@ -100,12 +88,10 @@ public sealed partial class App : Application
{ {
_viewModel?.Dispose(); _viewModel?.Dispose();
_orchestrator?.Dispose(); _orchestrator?.Dispose();
_manifestClient?.Dispose(); _updateComposition?.Dispose();
_updateHttpClient?.Dispose();
_viewModel = null; _viewModel = null;
_orchestrator = null; _orchestrator = null;
_manifestClient = null; _updateComposition = null;
_updateHttpClient = null;
} }
private static LauncherVersion GetLauncherVersion() private static LauncherVersion GetLauncherVersion()

View file

@ -0,0 +1,128 @@
using System.Net;
using System.Security;
using System.Text.Json;
using AcDream.Launcher.Core.Orchestration;
using AcDream.Launcher.Core.Updates;
using AcDream.Launcher.ViewModels;
using AcDream.Platform;
namespace AcDream.Launcher;
/// <summary>
/// Testable startup transaction for versioned-client/update services. Storage
/// failures produce a fail-closed executable resolver and an unavailable UI
/// projection; they do not abort profile/installer window construction.
/// </summary>
internal sealed class LauncherUpdateComposition : IDisposable
{
private readonly HttpClient? _artifactClient;
private readonly ReleaseManifestClient? _manifestClient;
private LauncherUpdateComposition(
ClientVersionStore versions,
LauncherExecutableSet executables,
ILauncherUpdater updater,
HttpClient? artifactClient,
ReleaseManifestClient? manifestClient)
{
Versions = versions;
Executables = executables;
Updater = updater;
_artifactClient = artifactClient;
_manifestClient = manifestClient;
}
public ClientVersionStore Versions { get; }
public LauncherExecutableSet Executables { get; }
public ILauncherUpdater Updater { get; }
public static LauncherUpdateComposition Create(
ApplicationPathSet paths,
string rid,
LauncherVersion launcherVersion,
string launcherTargetDirectory,
Func<bool> hasRunningSessions,
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
{
ArgumentNullException.ThrowIfNull(paths);
ArgumentNullException.ThrowIfNull(launcherVersion);
ArgumentNullException.ThrowIfNull(hasRunningSessions);
var versions = new ClientVersionStore(paths);
HttpClient? artifactClient = null;
ReleaseManifestClient? manifestClient = null;
try
{
_ = initialize is null
? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult()
: initialize(versions, rid);
artifactClient = new HttpClient(
new HttpClientHandler
{
AllowAutoRedirect = false,
UseCookies = false,
AutomaticDecompression = DecompressionMethods.None,
},
disposeHandler: true)
{
Timeout = TimeSpan.FromSeconds(15),
};
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
var updater = new LauncherUpdater(
manifestClient,
artifactClient,
versions,
selfUpdates,
launcherVersion,
rid,
launcherTargetDirectory,
hasRunningSessions);
return new LauncherUpdateComposition(
versions,
LauncherExecutableSet.FromCurrentVersionStore(versions),
updater,
artifactClient,
manifestClient);
}
catch (Exception ex) when (IsStorageFailure(ex))
{
manifestClient?.Dispose();
artifactClient?.Dispose();
string status = "Versioned client update storage is unavailable: "
+ (string.IsNullOrWhiteSpace(ex.Message)
? "the storage operation failed."
: ex.Message);
var resolution = new ClientVersionResolution(
ClientVersionState.Invalid,
status,
null,
null,
null,
null);
return new LauncherUpdateComposition(
versions,
LauncherExecutableSet.Unavailable(status),
new UnavailableLauncherUpdater(status, resolution),
artifactClient: null,
manifestClient: null);
}
}
public void Dispose()
{
_manifestClient?.Dispose();
_artifactClient?.Dispose();
}
private static bool IsStorageFailure(Exception exception) => exception is
IOException
or UnauthorizedAccessException
or SecurityException
or JsonException
or FormatException
or NotSupportedException
or LauncherUpdateException;
}

View file

@ -479,42 +479,52 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
{ {
private static readonly ClientVersionResolution Missing = new( private readonly ClientVersionResolution _resolution;
ClientVersionState.Missing, private readonly string _status;
"Versioned client updater is unavailable.",
null,
null,
null,
null);
public ClientVersionResolution CurrentClient => Missing; public UnavailableLauncherUpdater(
string status = "Versioned client updater is unavailable.",
ClientVersionResolution? resolution = null)
{
ArgumentException.ThrowIfNullOrWhiteSpace(status);
_status = status;
_resolution = resolution ?? new ClientVersionResolution(
ClientVersionState.Invalid,
status,
null,
null,
null,
null);
}
public ClientVersionResolution CurrentClient => _resolution;
public Task<ClientVersionResolution> InitializeAsync( public Task<ClientVersionResolution> InitializeAsync(
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Task.FromResult(Missing); Task.FromResult(_resolution);
public Task<LauncherUpdateCheckResult> CheckAsync( public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Task.FromException<LauncherUpdateCheckResult>( Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("Versioned client updater is unavailable.")); new LauncherUpdateException(_status));
public Task<ClientVersionResolution> InstallClientAsync( public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check, LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null, IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>( Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable.")); new LauncherUpdateException(_status));
public Task<SelfUpdateStageResult> StageLauncherAsync( public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check, LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null, IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Task.FromException<SelfUpdateStageResult>( Task.FromException<SelfUpdateStageResult>(
new LauncherUpdateException("Versioned client updater is unavailable.")); new LauncherUpdateException(_status));
public Task<ClientVersionResolution> RollbackClientAsync( public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null, IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) => CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>( Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable.")); new LauncherUpdateException(_status));
} }

View file

@ -5,15 +5,176 @@ using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Updates; using AcDream.Launcher.Core.Updates;
using AcDream.Platform; 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..]), if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument
"hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]), && Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid
"orphan-parent" => await RunOrphanParentAsync(args[1..]), && !string.IsNullOrWhiteSpace(helperPid))
"orphan-child" => RunOrphanChild(args[1..]), {
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, _ => 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) static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
{ {
if (arguments.Length != 4 if (arguments.Length != 4

View file

@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive(); 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] [Fact]
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential() public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
{ {
@ -756,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 private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
{ {
public List<QueueStatusSource> Created { get; } = []; public List<QueueStatusSource> Created { get; } = [];

View file

@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix))); 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) private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe" ? ".exe"
: string.Empty; : string.Empty;

View file

@ -1,23 +1,9 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AcDream.Launcher.Core.Updates; using AcDream.Launcher.Core.Updates;
namespace AcDream.Launcher.Core.Tests.Updates; namespace AcDream.Launcher.Core.Tests.Updates;
public sealed class LauncherSelfUpdateManagerTests : IDisposable public sealed class LauncherSelfUpdateManagerTests : IDisposable
{ {
private static readonly JsonSerializerOptions PlanOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Converters =
{
new JsonStringEnumConverter<SelfUpdatePlanState>(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
private readonly string _root = Path.Combine( private readonly string _root = Path.Combine(
Path.GetTempPath(), Path.GetTempPath(),
"acdream-self-update-tests", "acdream-self-update-tests",
@ -47,6 +33,12 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
await File.WriteAllTextAsync(unrelated, "preserve"); await File.WriteAllTextAsync(unrelated, "preserve");
Assert.Null(await harness.Manager.LoadPendingAsync()); 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(Directory.Exists(orphan));
Assert.False(File.Exists(temporary)); Assert.False(File.Exists(temporary));
@ -121,49 +113,91 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
} }
[Fact] [Fact]
public async Task CrashDuringApplyingReplaysReverseJournalToStagedState() 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.Staged, 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 ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState()
{ {
using var harness = new Harness(_root); using var harness = new Harness(_root);
_ = await harness.StageAsync(); _ = await harness.StageAsync();
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>( LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation =>
await harness.Manager.LoadPendingAsync());
SelfUpdateApplyEntry[] apply = staged.Files
.Select(file => new SelfUpdateApplyEntry(
file.Path,
File.Exists(Path.Combine(
harness.Target,
file.Path.Replace('/', Path.DirectorySeparatorChar)))))
.ToArray();
SelfUpdatePlan applying = staged with
{ {
State = SelfUpdatePlanState.Applying, if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
Apply = apply, && string.Equals(
}; observation.Path,
await File.WriteAllTextAsync( harness.LauncherName,
harness.Manager.PendingPlanPath, StringComparison.Ordinal))
JsonSerializer.Serialize(applying, PlanOptions)); {
Assert.True(File.Exists(harness.LauncherPath));
throw new InvalidOperationException("failpoint");
}
});
SelfUpdateApplyEntry first = apply[0]; InvalidOperationException failure = await Assert.ThrowsAsync<InvalidOperationException>(
string payload = Path.Combine( () => faulting.ApplyPendingAsync(harness.Target));
harness.Manager.GetPayloadDirectory(staged.TransactionId), SelfUpdatePlan recovered = Assert.IsType<SelfUpdatePlan>(
first.Path.Replace('/', Path.DirectorySeparatorChar)); await harness.Manager.LoadPendingAsync());
string target = Path.Combine(
harness.Target,
first.Path.Replace('/', Path.DirectorySeparatorChar));
string backup = Path.Combine(
harness.Manager.GetBackupDirectory(staged.TransactionId),
first.Path.Replace('/', Path.DirectorySeparatorChar));
Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
File.Move(target, backup);
File.Move(payload, target);
SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target);
Assert.Equal("failpoint", failure.Message);
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State); Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath)); Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath)); Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine( Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
harness.Manager.GetPayloadDirectory(staged.TransactionId), harness.Manager.GetPayloadDirectory(recovered.TransactionId),
harness.LauncherName))); harness.LauncherName)));
} }
@ -219,8 +253,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
Assert.False(confirmation.ShouldExit); Assert.False(confirmation.ShouldExit);
Assert.Empty(confirmation.RemainingArguments); Assert.Empty(confirmation.RemainingArguments);
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId)); Assert.False(File.Exists(harness.Manager.PendingPlanPath));
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target); Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
} }
private sealed class Harness : IDisposable private sealed class Harness : IDisposable
@ -229,9 +263,11 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
private readonly HttpClient _http = new(); private readonly HttpClient _http = new();
private readonly byte[] _archive; private readonly byte[] _archive;
private readonly ReleaseArtifact _artifact; private readonly ReleaseArtifact _artifact;
private readonly string _root;
public Harness(string root) public Harness(string root)
{ {
_root = root;
Target = Path.Combine(root, "published launcher"); Target = Path.Combine(root, "published launcher");
Directory.CreateDirectory(Target); Directory.CreateDirectory(Target);
Rid = LauncherRuntimeIdentity.DetectRid(); Rid = LauncherRuntimeIdentity.DetectRid();
@ -270,6 +306,25 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
progress: null, progress: null,
CancellationToken.None); 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() public void Dispose()
{ {
_http.Dispose(); _http.Dispose();

View file

@ -0,0 +1,453 @@
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 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 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);
}

View file

@ -30,7 +30,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2"); byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2");
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher); ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient(); using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths); var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater( var updater = new LauncherUpdater(
source, source,
@ -74,7 +75,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
byte[] launcher = UpdateTestData.LauncherZip(_rid); byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher); ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
using var http = new HttpClient(); using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths); var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater( var updater = new LauncherUpdater(
source, source,
@ -108,7 +110,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
byte[] launcher = UpdateTestData.LauncherZip(_rid); byte[] launcher = UpdateTestData.LauncherZip(_rid);
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher); ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
using var http = new HttpClient(); using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths); var versions = new ClientVersionStore(_paths);
bool running = true; bool running = true;
var updater = new LauncherUpdater( var updater = new LauncherUpdater(
@ -156,7 +159,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
launcher), launcher),
contentType: "application/json"); contentType: "application/json");
using var http = new HttpClient(); using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
var versions = new ClientVersionStore(_paths); var versions = new ClientVersionStore(_paths);
var updater = new LauncherUpdater( var updater = new LauncherUpdater(
source, source,

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Text; using System.Text;
using AcDream.Launcher.Core.Updates; using AcDream.Launcher.Core.Updates;
@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests
launcher), launcher),
contentType: "application/json"); contentType: "application/json");
using var http = new HttpClient(); using var http = new HttpClient();
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json")); using var source = ReleaseManifestClient.CreateLoopbackFixture(
server.UriFor("manifest.json"));
ReleaseManifest manifest = await source.FetchAsync(); ReleaseManifest manifest = await source.FetchAsync();
@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests
ValidJson().Replace("\"size\":12", "\"size\":0"), ValidJson().Replace("\"size\":12", "\"size\":0"),
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"), ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""), ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
ValidJson().Replace("http://127.0.0.1", "http://example.test"), 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() => private static string ValidJson() =>
"{\"schemaVersion\":1,\"version\":\"2.0.0\"," $$$$"""
+ "\"minimumLauncherVersion\":\"1.0.0\"," {"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}}}
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\"," """;
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\"," private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}"; {
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 public sealed class VerifiedArtifactDownloaderTests : IDisposable
@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable
Assert.Equal("preserve", await File.ReadAllTextAsync(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) private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
: IProgress<ArtifactDownloadProgress> : IProgress<ArtifactDownloadProgress>
{ {
public void Report(ArtifactDownloadProgress value) => callback(value); 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);
}
}
} }

View file

@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable
[InlineData("a/./b")] [InlineData("a/./b")]
[InlineData("CON")] [InlineData("CON")]
[InlineData("aux.txt")] [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.")]
[InlineData("trailing ")] [InlineData("trailing ")]
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry) public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable
Assert.False(File.Exists(Path.Combine(_root, "escape"))); 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] [Fact]
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction() public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
{ {

View file

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