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`
pointer swap, refuse while any session runs, keep previous version for
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.
**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
`eriknihlen/acdream`; the launcher reads
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
Tests may inject a loopback HTTP URI, but production artifacts and redirects
must use HTTPS. `manifest.json` is:
Tests use a separate internal fixture constructor that may admit loopback HTTP;
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
{
@ -557,14 +561,18 @@ same-volume directory rename.
write-through temporary-file + same-directory atomic rename. The last valid
pointer is also atomically preserved as `current.previous.json`; startup may
restore that exact backup only when `current.json` is missing/malformed and
the referenced version verifies. Orphan LA10 staging directories and pointer
temporaries are transaction-owned by exact names and are removed under the
update lease. A corrupt installed version is never silently selected; the
explicit one-step rollback swaps the two verified pointer versions.
the referenced version verifies. Orphan LA10 staging directories, download
archives, corrupt-version quarantine directories, and pointer temporaries are
transaction-owned by exact lowercase GUID names and are removed only under the
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
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
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,
@ -572,11 +580,11 @@ owns the lease and therefore releases after process death.
Launcher self-update staging lives at
`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
{
"schemaVersion": 1,
"schemaVersion": 2,
"transactionId": "0123456789abcdef0123456789abcdef",
"state": "staged",
"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
atomically advances the plan to `applying` and fills `apply` with each path's
`hadOriginal` bit. It waits for the initiating launcher PID without invoking a
shell, moves originals into the transaction backup tree, then moves verified
staged files into place. It never opens a target with truncate/overwrite. On
success the plan becomes `awaitingConfirmation`; the new launcher confirms at
its first managed instruction, after which backup and plan cleanup is safe. An
Before mutation the verified staged launcher becomes the next-start helper and
waits for the initiating launcher PID without invoking a shell. It first copies
the complete verified payload into the target-local
`.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
to `applying`; `apply` is an ordinally sorted union of new payload paths, the
owned metadata path, and obsolete paths from the previous ownership record:
```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
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
target directory, which must equal the actual launcher base directory.
Reading `pending.json` never performs cleanup. Ordinary startup attempts the
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

View file

@ -298,19 +298,19 @@ preview would be a deliberate divergence we are NOT taking.
install to `DataDirectory/app/<version>/`; atomic pointer swap
(`current.json`); never while any session is running; keep the previous
version for one-step rollback.
- **Launcher self-update:** same feed; staged download; rename-dance swap
on next start (a running exe can't replace itself on Windows).
- **Launcher self-update:** same feed; staged download; target-local atomic
replacement on next start after the running process exits.
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
are release assets; the launcher pins the repo/owner in its config.
The exact v1 manifest, extracted-version record, `current.json` activation
pointer, shared-session/exclusive-update OS lease, and durable self-update
plan are pinned in
pointer and launcher ownership record, shared-session/exclusive-update OS
lease, and durable self-update plan schema 2 are pinned in
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
contracts (v1, BINDING)**. That section is normative: implementations reject
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
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
configs; LA10 changes only which verified `app/current.json` client binaries
the process supervisor executes.

View file

@ -44,6 +44,7 @@ public sealed class LauncherProcessSupervisorFactory(
/// </summary>
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
{
private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5);
private readonly ILauncherChildProcessFactory _factory;
private readonly object _gate = new();
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
private LauncherSessionState _state = LauncherSessionState.Starting;
private int? _exitCode;
private bool _publishingStateChanges;
private bool _disposed;
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
{
@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
ILauncherChildProcess process;
lock (_gate)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (_process is not null)
{
throw new InvalidOperationException(
@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
if (!process.WaitForExit(timeout) && !process.HasExited)
{
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()
{
ILauncherChildProcess? process;
lock (_gate)
{
if (_disposed)
{
return;
}
_disposed = true;
process = _process;
}
if (process is { HasExited: false })
{
Stop(DisposeStopTimeout);
}
lock (_gate)
{
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)
{
LauncherCapability capability = GetAvailability(mode);

View file

@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
request.Cancellation.Dispose();
request.Activity.StartCompleted.Set();
}
}
@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
private static void DisposeActivity(ManagedActivity activity)
{
activity.StartCancellation?.Cancel();
activity.StartCompleted.Wait();
activity.StartCancellation?.Dispose();
activity.StartCancellation = 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)
{
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
}
ReleaseUpdateSessionLease(activity);
activity.StartCompleted.Dispose();
}
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
public ManualResetEventSlim StartCompleted { get; } = new(false);
public object StatusReadGate { get; } = new();
public bool IsActive => State is not (

View file

@ -503,7 +503,9 @@ public sealed class ClientVersionStore
.Where(path => !string.Equals(
path,
"install.json",
StringComparison.OrdinalIgnoreCase))
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal))
.OrderBy(path => path, StringComparer.Ordinal)
.ToArray();
string[] recordedFiles = record.Files
@ -809,13 +811,43 @@ public sealed class ClientVersionStore
".client-staging-*",
SearchOption.TopDirectoryOnly))
{
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
if (Guid.TryParseExact(suffix, "N", out _))
if (HasCanonicalGuidName(
Path.GetFileName(path),
".client-staging-",
string.Empty))
{
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(
AppDirectory,
".current*.tmp",
@ -825,20 +857,43 @@ public sealed class ClientVersionStore
string[] parts = fileName.Split('.');
if (parts.Length >= 4
&& 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);
}
}
}
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)
{
string parent = Path.GetDirectoryName(path) ?? string.Empty;
string fileName = Path.GetFileName(path);
if (!PathsEqual(parent, AppDirectory)
|| !fileName.StartsWith(".client-staging-", StringComparison.Ordinal)
|| !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _))
|| !HasCanonicalGuidName(
fileName,
".client-staging-",
string.Empty))
{
throw new LauncherUpdateException(
"The client extraction path is not an owned LA10 staging directory.");
@ -901,7 +956,7 @@ public sealed class ClientVersionStore
&& !part.Any(character =>
char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*')
&& !IsWindowsDeviceName(part));
&& !PortablePathRules.IsWindowsDeviceName(part));
}
internal static string ResolveContained(string root, string relative)
@ -929,19 +984,6 @@ public sealed class ClientVersionStore
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) =>
string.Equals(
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),

View file

@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult(
string[] RemainingArguments);
/// <summary>
/// Process-level rename dance for launcher self-update. Every child argument
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
/// shell command.
/// Process-level self-update bootstrap. Every child argument is passed through
/// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
/// </summary>
public static class LauncherSelfUpdateBootstrap
{
public const string HelperArgument = "--acdream-self-update-helper-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 CleanupTimeout = TimeSpan.FromSeconds(5);
public static async Task<SelfUpdateStartupResult> HandleAsync(
string[] args,
@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap
Path.GetFullPath(launcherBaseDirectory));
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
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
{
if (args.Length != 4
if (args.Length < 4
|| !int.TryParse(
args[1],
System.Globalization.NumberStyles.None,
@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap
parentPid,
args[2],
args[3],
args[4..],
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(true, exitCode, []);
@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap
if (args.Length > 0
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
{
if (args.Length != 2)
if (args.Length < 2)
{
return new SelfUpdateStartupResult(true, 64, []);
}
@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap
executable,
cancellationToken)
.ConfigureAwait(false);
return new SelfUpdateStartupResult(false, 0, []);
}
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,
await FinishConfirmedCleanupAsync(
manager,
baseDirectory,
cancellationToken)
.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);
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
string expectedExecutable = ClientVersionStore.ResolveContained(
baseDirectory,
"acdream-launcher" + suffix);
if (!PathsEqual(executable, expectedExecutable))
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
{
throw new LauncherUpdateException(
"Self-update can start only from the published acdream-launcher executable.");
}
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
.ConfigureAwait(false);
_ = manager.CleanupOwnedResidueUnderLease(
plan,
baseDirectory,
lease);
if (plan is null)
{
return new SelfUpdateStartupResult(false, 0, args);
}
string helperPath = manager.GetHelperPath(plan.TransactionId);
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!);
VerifiedArtifactDownloader.TryDelete(helperPath);
File.Copy(executable, helperPath, overwrite: false);
if (OperatingSystem.IsLinux())
{
File.SetUnixFileMode(
helperPath,
UnixFileMode.UserRead
| UnixFileMode.UserWrite
| UnixFileMode.UserExecute);
}
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
{
throw new LauncherUpdateException(
"The pending self-update targets a different launcher directory.");
}
var startInfo = new ProcessStartInfo(helperPath)
{
UseShellExecute = false,
WorkingDirectory = manager.GetTransactionDirectory(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);
_ = Process.Start(startInfo)
?? throw new LauncherUpdateException(
"The launcher self-update helper could not be started.");
return new SelfUpdateStartupResult(true, 0, []);
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,
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(
@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap
int parentPid,
string targetDirectory,
string transactionId,
IReadOnlyList<string> publicArguments,
CancellationToken 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.");
}
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
{
throw new LauncherUpdateException(
"The helper target does not match the pending self-update.");
}
string launcherPath = ClientVersionStore.ResolveContained(
targetDirectory,
"acdream-launcher" + suffix);
GetLauncherFileName(plan.Rid));
var startInfo = new ProcessStartInfo(launcherPath)
{
UseShellExecute = false,
@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap
};
startInfo.ArgumentList.Add(ConfirmArgument);
startInfo.ArgumentList.Add(transactionId);
Process? replacement = null;
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
bool appliedByThisHelper = false;
try
foreach (string argument in publicArguments)
{
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
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;
startInfo.ArgumentList.Add(argument);
}
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);
await replacement.WaitForExitAsync(CancellationToken.None)
.ConfigureAwait(false);
throw new LauncherUpdateException(
"The pending self-update changed before the helper acquired its lease.");
}
_ = manager.CleanupOwnedResidueUnderLease(
plan,
targetDirectory,
lease);
Process? replacement = null;
bool appliedByThisHelper = false;
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.None)
.ConfigureAwait(false);
if (pending?.State == SelfUpdatePlanState.Applying)
cancellationToken.ThrowIfCancellationRequested();
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
{
_ = await manager.RecoverApplyingAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
}
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
{
_ = await manager.RollbackAwaitingConfirmationAsync(
targetDirectory,
CancellationToken.None)
.ConfigureAwait(false);
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
{
// Do not start an executable from an ambiguous half-applied
// state. A subsequent startup replays the durable journal.
return 75;
}
if (replacement is { HasExited: false })
{
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,
WorkingDirectory = Path.GetFullPath(targetDirectory),
};
_ = Process.Start(restored);
return 74;
}
finally
{
replacement?.Dispose();
updateLease?.Dispose();
replacement?.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(
int parentPid,
CancellationToken cancellationToken)

View file

@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater
try
{
RefuseRunningSessions();
using UpdateSessionBarrier.ExclusiveLease lease =
_versions.Barrier.AcquireExclusive();
RefuseRunningSessions();
if (check.Manifest.Version <= _launcherVersion)
{
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>
/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is
/// accepted only for a loopback fixture; production and artifact URLs are
/// HTTPS-only.
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
/// construction is HTTPS-only. The loopback HTTP allowance is available 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>
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
{
@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
public const string GitHubRepository = "acdream";
public const int MaximumManifestBytes = 1024 * 1024;
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
public const int MaximumRedirects = 5;
public static Uri ProductionManifestUri { get; } = new(
$"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 bool _ownsHttpClient;
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(
CancellationToken cancellationToken = default)
{
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(
_manifestUri,
HttpCompletionOption.ResponseHeadersRead,
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)
Uri current = _manifestUri;
var visited = new HashSet<string>(StringComparer.Ordinal);
for (int redirectCount = 0;;)
{
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)
RequireTransport(current, "manifest redirect", _allowLoopbackHttp);
if (!visited.Add(current.AbsoluteUri))
{
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)
{
@ -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
{
@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
RejectDuplicateProperties(document.RootElement, "$" );
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
SerializerOptions);
return Validate(value);
return Validate(value, allowLoopbackHttpArtifacts);
}
catch (LauncherUpdateException)
{
@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
}
}
public void Dispose()
{
if (_ownsHttpClient)
{
_httpClient.Dispose();
}
}
public void Dispose() => _httpClient.Dispose();
internal static void RequireSecureOrLoopback(Uri uri, string description)
internal static void RequireTransport(
Uri uri,
string description,
bool allowLoopbackHttp)
{
if (!uri.IsAbsoluteUri
|| (uri.Scheme != Uri.UriSchemeHttps
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
&& !(allowLoopbackHttp
&& uri.Scheme == Uri.UriSchemeHttp
&& uri.IsLoopback)))
{
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)
{
@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
throw new LauncherUpdateException(
"The minimum launcher version cannot exceed the release version.");
}
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
document.Clients,
"clients");
"clients",
allowLoopbackHttpArtifacts);
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
document.Launchers,
"launchers");
"launchers",
allowLoopbackHttpArtifacts);
return new ReleaseManifest(version, minimum, clients, launchers);
}
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
Dictionary<string, ArtifactDocument>? artifacts,
string field)
string field,
bool allowLoopbackHttpArtifacts)
{
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.");
}
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
RequireTransport(
uri,
$"{field}.{rid} artifact",
allowLoopbackHttpArtifacts);
if (!IsSha256(value.Sha256))
{
throw new LauncherUpdateException(
@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
internal static bool IsSha256(string? value) =>
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)
{
if (element.ValueKind == JsonValueKind.Object)

View file

@ -297,7 +297,7 @@ public sealed class SafeZipExtractor
|| segment.Any(character =>
char.IsControl(character)
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|| IsWindowsDeviceName(segment))
|| PortablePathRules.IsWindowsDeviceName(segment))
{
throw new LauncherUpdateException(
$"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)
{
try

View file

@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier
FileShare.None,
"A launcher session or another update transaction is running. "
+ "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)
@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier
public sealed class ExclusiveLease : IDisposable
{
private readonly UpdateSessionBarrier _owner;
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();
}

View file

@ -1,4 +1,5 @@
using System.Buffers;
using System.Net;
using System.Security.Cryptography;
namespace AcDream.Launcher.Core.Updates;
@ -55,15 +56,11 @@ public sealed class VerifiedArtifactDownloader
bool ownsDestination = false;
try
{
using HttpResponseMessage response = await _httpClient.GetAsync(
using HttpResponseMessage response = await SendWithValidatedRedirectsAsync(
artifact.Url,
HttpCompletionOption.ResponseHeadersRead,
cancellationToken)
.ConfigureAwait(false);
response.EnsureSuccessStatusCode();
ReleaseManifestClient.RequireSecureOrLoopback(
response.RequestMessage?.RequestUri ?? artifact.Url,
"artifact redirect");
if (response.Content.Headers.ContentLength is long contentLength
&& 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)
{
try

View file

@ -14,6 +14,10 @@
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
</PropertyGroup>
<ItemGroup>
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" 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 LauncherWindowViewModel? _viewModel;
private HttpClient? _updateHttpClient;
private ReleaseManifestClient? _manifestClient;
private LauncherUpdateComposition? _updateComposition;
public override void Initialize() => AvaloniaXamlLoader.Load(this);
@ -52,38 +51,27 @@ public sealed partial class App : Application
$"Client content verification failed: {ex.Message}");
}
var clientVersions = new ClientVersionStore(paths);
_ = clientVersions.LoadAndRecoverAsync(rid)
.GetAwaiter()
.GetResult();
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
paths,
rid,
GetLauncherVersion(),
AppContext.BaseDirectory,
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
== true);
_updateComposition = updates;
_orchestrator = new LauncherOrchestrator(
profiles,
paths,
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
updates.Executables,
verification.Record,
installationStatus: verification.Status,
updateSessionBarrier: clientVersions.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));
updateSessionBarrier: updates.Versions.Barrier);
_viewModel = new LauncherWindowViewModel(
_orchestrator,
new AvaloniaUiDispatcher(),
installer,
updater);
updates.Updater);
_viewModel.Initialize();
desktop.MainWindow = new MainWindow
@ -100,12 +88,10 @@ public sealed partial class App : Application
{
_viewModel?.Dispose();
_orchestrator?.Dispose();
_manifestClient?.Dispose();
_updateHttpClient?.Dispose();
_updateComposition?.Dispose();
_viewModel = null;
_orchestrator = null;
_manifestClient = null;
_updateHttpClient = null;
_updateComposition = null;
}
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
{
private static readonly ClientVersionResolution Missing = new(
ClientVersionState.Missing,
"Versioned client updater is unavailable.",
null,
null,
null,
null);
private readonly ClientVersionResolution _resolution;
private readonly string _status;
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(
CancellationToken cancellationToken = default) =>
Task.FromResult(Missing);
Task.FromResult(_resolution);
public Task<LauncherUpdateCheckResult> CheckAsync(
CancellationToken cancellationToken = default) =>
Task.FromException<LauncherUpdateCheckResult>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
public Task<ClientVersionResolution> InstallClientAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<ClientVersionResolution>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
public Task<SelfUpdateStageResult> StageLauncherAsync(
LauncherUpdateCheckResult check,
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
Task.FromException<SelfUpdateStageResult>(
new LauncherUpdateException("Versioned client updater is unavailable."));
new LauncherUpdateException(_status));
public Task<ClientVersionResolution> RollbackClientAsync(
IProgress<LauncherUpdateProgress>? progress = null,
CancellationToken cancellationToken = default) =>
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.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..]),
"hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]),
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
"orphan-child" => RunOrphanChild(args[1..]),
if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument
&& Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid
&& !string.IsNullOrWhiteSpace(helperPid))
{
File.WriteAllText(
Path.GetFullPath(helperPid),
Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
}
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
effectiveArgs,
manager,
Path.GetFullPath(selfUpdateTarget),
Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
if (startup.ShouldExit)
{
return startup.ExitCode;
}
effectiveArgs = startup.RemainingArguments;
}
return effectiveArgs.FirstOrDefault() switch
{
"hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]),
"hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]),
"orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]),
"orphan-child" => RunOrphanChild(effectiveArgs[1..]),
"crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]),
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
_ => 2,
};
static bool IsBootstrapInvocation(string[] arguments) =>
arguments.Length > 0
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
or LauncherSelfUpdateBootstrap.ConfirmArgument
or LauncherSelfUpdateBootstrap.DeferredArgument
or "canonical-probe";
static ApplicationPathSet Paths(string dataDirectory)
{
string data = Path.GetFullPath(dataDirectory);
return new ApplicationPathSet(
Path.Combine(data, "fixture-config"),
data,
Path.Combine(data, "fixture-cache"),
null);
}
static async Task<int> CrashSelfUpdateAsync(string[] arguments)
{
if (arguments.Length != 4)
{
return 2;
}
string dataDirectory = Path.GetFullPath(arguments[0]);
string targetDirectory = Path.GetFullPath(arguments[1]);
string readyPath = Path.GetFullPath(arguments[2]);
string canonicalName = arguments[3];
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(
Paths(dataDirectory),
http,
null,
observation =>
{
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
&& string.Equals(
observation.Path,
canonicalName,
StringComparison.Ordinal))
{
if (!File.Exists(Path.Combine(targetDirectory, canonicalName)))
{
throw new InvalidOperationException(
"The canonical launcher vanished at the apply boundary.");
}
File.WriteAllText(readyPath, Environment.ProcessId.ToString(
System.Globalization.CultureInfo.InvariantCulture));
Thread.Sleep(Timeout.Infinite);
}
});
using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive();
_ = await manager.ApplyPendingAsync(targetDirectory);
return 0;
}
static async Task<int> StageSelfUpdateAsync(string[] arguments)
{
if (arguments.Length != 7
|| !long.TryParse(
arguments[6],
System.Globalization.NumberStyles.None,
System.Globalization.CultureInfo.InvariantCulture,
out long size))
{
return 2;
}
string dataDirectory = Path.GetFullPath(arguments[0]);
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http);
_ = await manager.StageAsync(
LauncherVersion.Parse(arguments[2]),
arguments[3],
new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size),
Path.GetFullPath(arguments[1]),
progress: null,
CancellationToken.None);
return 0;
}
static async Task<int> BootstrapProbeAsync(string[] arguments)
{
if (arguments.Length != 4)
{
return 2;
}
using var http = new HttpClient();
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
["ordinary"],
manager,
Path.GetFullPath(arguments[1]),
Path.GetFullPath(arguments[2]));
File.WriteAllText(
Path.GetFullPath(arguments[3]),
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));
return result.ShouldExit ? 3 : 0;
}
static int CanonicalProbe(string[] arguments)
{
if (arguments.Length != 1)
{
return 2;
}
File.WriteAllText(
Path.GetFullPath(arguments[0]),
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
+ "|"
+ Path.GetFullPath(
Environment.ProcessPath
?? throw new InvalidOperationException("Process path is unavailable.")));
return 0;
}
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
{
if (arguments.Length != 4

View file

@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
}
[Fact]
public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal()
{
var supervisors = new BlockingStopSupervisorFactory();
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
LauncherOrchestrator orchestrator = CreateOrchestrator(
supervisorFactory: supervisors,
updateSessionBarrier: barrier);
try
{
_ = await orchestrator.LaunchAsync(
"Local ACE",
"testaccount",
"+Acdream",
LaunchMode.Headless);
BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created);
Task disposal = Task.Run(orchestrator.Dispose);
Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5)));
Assert.False(disposal.IsCompleted);
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
supervisor.AllowTerminal.Set();
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
Assert.True(supervisor.Disposed);
}
finally
{
supervisors.AllowEveryStop();
orchestrator.Dispose();
}
}
[Fact]
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
{
@ -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
{
public List<QueueStatusSource> Created { get; } = [];

View file

@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
}
[Fact]
public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent()
{
if (!OperatingSystem.IsLinux())
{
return;
}
var store = new ClientVersionStore(_paths);
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case");
await File.WriteAllTextAsync(
Path.Combine(installed.Directory!, "INSTALL.JSON"),
"must-not-be-hidden");
ClientVersionResolution resolution = await new ClientVersionStore(_paths)
.LoadAndRecoverAsync(_rid);
Assert.Equal(ClientVersionState.Invalid, resolution.State);
Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue()
{
var store = new ClientVersionStore(_paths);
Directory.CreateDirectory(store.AppDirectory);
string id = Guid.NewGuid().ToString("N");
string nearId = id[..31] + "g";
string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id);
string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id);
string exactDownload = Path.Combine(
store.AppDirectory,
".client-download-" + id + ".zip");
string nearStaging = exactStaging + "-user";
string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId);
string nearDownload = Path.Combine(
store.AppDirectory,
".client-download-" + id + ".zip.user");
Directory.CreateDirectory(exactStaging);
Directory.CreateDirectory(exactCorrupt);
Directory.CreateDirectory(nearStaging);
Directory.CreateDirectory(nearCorrupt);
await File.WriteAllTextAsync(exactDownload, "owned");
await File.WriteAllTextAsync(nearDownload, "preserve");
_ = await store.LoadAndRecoverAsync(_rid);
Assert.False(Directory.Exists(exactStaging));
Assert.False(Directory.Exists(exactCorrupt));
Assert.False(File.Exists(exactDownload));
Assert.True(Directory.Exists(nearStaging));
Assert.True(Directory.Exists(nearCorrupt));
Assert.True(File.Exists(nearDownload));
}
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
? ".exe"
: string.Empty;

View file

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

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

View file

@ -1,3 +1,4 @@
using System.Net;
using System.Text;
using AcDream.Launcher.Core.Updates;
@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests
launcher),
contentType: "application/json");
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();
@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests
ValidJson().Replace("\"size\":12", "\"size\":0"),
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
ValidJson().Replace("http://127.0.0.1", "http://example.test"),
ValidJson().Replace("https://example.test/client", "http://example.test/client"),
};
[Theory]
[InlineData("clients", "client")]
[InlineData("launchers", "launcher")]
public void ProductionManifestRejectsLoopbackHttpArtifacts(
string section,
string artifact)
{
string json = ValidJson().Replace(
$"https://example.test/{artifact}",
$"http://127.0.0.1/{artifact}",
StringComparison.Ordinal);
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
Assert.Contains(section, error.Message, StringComparison.Ordinal);
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
}
[Fact]
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
{
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.Found,
new Uri("http://127.0.0.1/manifest.json")));
using var source = ReleaseManifestClient.CreateForTransportTest(
ReleaseManifestClient.ProductionManifestUri,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
}
[Fact]
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
{
var start = new Uri("https://example.test/start");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.TemporaryRedirect,
new Uri("http://example.test/plaintext-hop")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
Assert.Equal([start], handler.Requests);
}
[Fact]
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
{
var first = new Uri("https://example.test/first");
var second = new Uri("https://example.test/second");
var handler = new SequenceHandler((request, _) => Redirect(
HttpStatusCode.PermanentRedirect,
request.RequestUri == first ? second : first));
using var source = ReleaseManifestClient.CreateForTransportTest(
first,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal([first, second], handler.Requests);
}
[Fact]
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
{
var start = new Uri("https://example.test/hop-0");
var handler = new SequenceHandler((_, index) => Redirect(
HttpStatusCode.Found,
new Uri($"https://example.test/hop-{index + 1}")));
using var source = ReleaseManifestClient.CreateForTransportTest(
start,
allowLoopbackHttp: false,
handler);
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
() => source.FetchAsync());
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
Assert.Equal(6, handler.Requests.Count);
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
}
private static string ValidJson() =>
"{\"schemaVersion\":1,\"version\":\"2.0.0\","
+ "\"minimumLauncherVersion\":\"1.0.0\","
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\","
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\","
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}";
$$$$"""
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
""";
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
{
var response = new HttpResponseMessage(status);
response.Headers.Location = location;
return response;
}
private sealed class SequenceHandler(
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
: HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Uri uri = request.RequestUri
?? throw new InvalidOperationException("Test request has no URI.");
int index = Requests.Count;
Requests.Add(uri);
return Task.FromResult(respond(request, index));
}
}
}
public sealed class VerifiedArtifactDownloaderTests : IDisposable
@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
}
[Fact]
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
{
var handler = new RedirectHandler();
using var http = new HttpClient(handler);
var downloader = new VerifiedArtifactDownloader(http);
string destination = Path.Combine(_root, "redirect.zip");
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
downloader.DownloadAsync(
new ReleaseArtifact(
new Uri("https://example.test/artifact"),
new string('a', 64),
12),
destination));
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
Assert.False(File.Exists(destination));
}
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
: IProgress<ArtifactDownloadProgress>
{
public void Report(ArtifactDownloadProgress value) => callback(value);
}
private sealed class RedirectHandler : HttpMessageHandler
{
public List<Uri> Requests { get; } = [];
protected override Task<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
Requests.Add(request.RequestUri!);
var response = new HttpResponseMessage(HttpStatusCode.Found);
response.Headers.Location = new Uri("http://example.test/plaintext");
return Task.FromResult(response);
}
}
}

View file

@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable
[InlineData("a/./b")]
[InlineData("CON")]
[InlineData("aux.txt")]
[InlineData("CLOCK$/value")]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/value")]
[InlineData("COM¹.dll")]
[InlineData("com²/value")]
[InlineData("LPT³.log")]
[InlineData("trailing.")]
[InlineData("trailing ")]
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable
Assert.False(File.Exists(Path.Combine(_root, "escape")));
}
[Theory]
[InlineData("CONIN$.txt")]
[InlineData("CONOUT$/child")]
[InlineData("COM¹.dll")]
[InlineData("LPT³/child")]
[InlineData("CLOCK$")]
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
[Fact]
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
{

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