fix(launcher): harden updater crash recovery
This commit is contained in:
parent
2d2a5b5046
commit
1955ca8ab5
27 changed files with 2714 additions and 544 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
38
src/AcDream.Launcher.Core/Updates/PortablePathRules.cs
Normal file
38
src/AcDream.Launcher.Core/Updates/PortablePathRules.cs
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
128
src/AcDream.Launcher/LauncherUpdateComposition.cs
Normal file
128
src/AcDream.Launcher/LauncherUpdateComposition.cs
Normal 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;
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; } = [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue