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`
|
verify, unpack to `DataDirectory/app/<version>/`, atomic `current.json`
|
||||||
pointer swap, refuse while any session runs, keep previous version for
|
pointer swap, refuse while any session runs, keep previous version for
|
||||||
one-step rollback.
|
one-step rollback.
|
||||||
- Launcher self-update: staged download + rename-dance on next start.
|
- Launcher self-update: staged download + target-local atomic replacement on
|
||||||
|
next start.
|
||||||
- Session-config composition targets `app/current`'s binaries.
|
- Session-config composition targets `app/current`'s binaries.
|
||||||
|
|
||||||
**Acceptance:** manifest/download/verify/swap tests against a local HTTP
|
**Acceptance:** manifest/download/verify/swap tests against a local HTTP
|
||||||
|
|
@ -488,8 +489,11 @@ before doing network, extraction, or activation work.
|
||||||
The production feed is pinned to GitHub owner/repository
|
The production feed is pinned to GitHub owner/repository
|
||||||
`eriknihlen/acdream`; the launcher reads
|
`eriknihlen/acdream`; the launcher reads
|
||||||
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
|
`https://github.com/eriknihlen/acdream/releases/latest/download/manifest.json`.
|
||||||
Tests may inject a loopback HTTP URI, but production artifacts and redirects
|
Tests use a separate internal fixture constructor that may admit loopback HTTP;
|
||||||
must use HTTPS. `manifest.json` is:
|
that allowance never propagates to the production feed. Production manifest
|
||||||
|
and artifact URIs use HTTPS. Automatic redirects are disabled and every
|
||||||
|
redirect hop is validated before it is requested; redirect loops, a chain over
|
||||||
|
five hops, and any HTTPS-to-HTTP downgrade are rejected. `manifest.json` is:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
|
@ -557,14 +561,18 @@ same-volume directory rename.
|
||||||
write-through temporary-file + same-directory atomic rename. The last valid
|
write-through temporary-file + same-directory atomic rename. The last valid
|
||||||
pointer is also atomically preserved as `current.previous.json`; startup may
|
pointer is also atomically preserved as `current.previous.json`; startup may
|
||||||
restore that exact backup only when `current.json` is missing/malformed and
|
restore that exact backup only when `current.json` is missing/malformed and
|
||||||
the referenced version verifies. Orphan LA10 staging directories and pointer
|
the referenced version verifies. Orphan LA10 staging directories, download
|
||||||
temporaries are transaction-owned by exact names and are removed under the
|
archives, corrupt-version quarantine directories, and pointer temporaries are
|
||||||
update lease. A corrupt installed version is never silently selected; the
|
transaction-owned by exact lowercase GUID names and are removed only under the
|
||||||
explicit one-step rollback swaps the two verified pointer versions.
|
exclusive update lease; near-matching user names are preserved. A corrupt
|
||||||
|
installed version is never silently selected; the explicit one-step rollback
|
||||||
|
swaps the two verified pointer versions.
|
||||||
|
|
||||||
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
|
`DataDirectory/app/.update-session.lock` is the cross-process barrier. Each
|
||||||
supervised launcher activity holds a shared OS handle from before executable
|
supervised launcher activity holds a shared OS handle from before executable
|
||||||
resolution until terminal process observation; an update/rollback holds the
|
resolution until terminal process observation; launcher disposal requests
|
||||||
|
child termination and does not release that handle until the child is actually
|
||||||
|
observed terminal. An update/rollback holds the
|
||||||
exclusive handle for its entire recovery/download/extract/promote/pointer
|
exclusive handle for its entire recovery/download/extract/promote/pointer
|
||||||
transaction. Failure to acquire the exclusive handle is an immediate refusal,
|
transaction. Failure to acquire the exclusive handle is an immediate refusal,
|
||||||
not a wait behind a running session. The open handle, not lock-file contents,
|
not a wait behind a running session. The open handle, not lock-file contents,
|
||||||
|
|
@ -572,11 +580,11 @@ owns the lease and therefore releases after process death.
|
||||||
|
|
||||||
Launcher self-update staging lives at
|
Launcher self-update staging lives at
|
||||||
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
|
`DataDirectory/launcher-update/transactions/<transactionId>/` and the sole
|
||||||
durable authority is `DataDirectory/launcher-update/pending.json`:
|
durable authority is `DataDirectory/launcher-update/pending.json` (schema 2):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"schemaVersion": 1,
|
"schemaVersion": 2,
|
||||||
"transactionId": "0123456789abcdef0123456789abcdef",
|
"transactionId": "0123456789abcdef0123456789abcdef",
|
||||||
"state": "staged",
|
"state": "staged",
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
|
|
@ -591,17 +599,62 @@ durable authority is `DataDirectory/launcher-update/pending.json`:
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Before mutation a next-start helper copied outside the target directory
|
Before mutation the verified staged launcher becomes the next-start helper and
|
||||||
atomically advances the plan to `applying` and fills `apply` with each path's
|
waits for the initiating launcher PID without invoking a shell. It first copies
|
||||||
`hadOriginal` bit. It waits for the initiating launcher PID without invoking a
|
the complete verified payload into the target-local
|
||||||
shell, moves originals into the transaction backup tree, then moves verified
|
`.acdream-self-update-<transactionId>/incoming/` tree. The plan then advances
|
||||||
staged files into place. It never opens a target with truncate/overwrite. On
|
to `applying`; `apply` is an ordinally sorted union of new payload paths, the
|
||||||
success the plan becomes `awaitingConfirmation`; the new launcher confirms at
|
owned metadata path, and obsolete paths from the previous ownership record:
|
||||||
its first managed instruction, after which backup and plan cleanup is safe. An
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "path": "acdream-launcher.exe", "operation": "install", "hadOriginal": true },
|
||||||
|
{ "path": "obsolete.dll", "operation": "remove", "hadOriginal": true }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing targets are replaced with one same-filesystem atomic replace whose
|
||||||
|
backup is also target-local. Previously absent noncanonical files use one
|
||||||
|
same-filesystem rename; obsolete owned files use one rename into backup. The
|
||||||
|
canonical launcher path therefore contains either the complete old file or the
|
||||||
|
complete new file at every durable crash boundary. Rollback reverses the same
|
||||||
|
operations atomically and is idempotent after a process/power loss. Linux mode
|
||||||
|
bits come from the verified incoming file. A helper that cannot immediately
|
||||||
|
acquire the exclusive update lease defers the staged plan and exits without
|
||||||
|
restarting the old launcher, preventing restart loops.
|
||||||
|
|
||||||
|
Successful application writes strict target ownership metadata at
|
||||||
|
`<launcher directory>/launcher.install.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"version": "1.2.3",
|
||||||
|
"rid": "win-x64",
|
||||||
|
"files": [
|
||||||
|
{ "path": "acdream-launcher.exe", "sha256": "<64 hex characters>", "size": 123, "unixMode": 0 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The archive may not supply that reserved metadata path. A prior valid record is
|
||||||
|
the only authority for obsolete-file removal; the first managed update does
|
||||||
|
not infer ownership of unrelated legacy files. On success the plan becomes
|
||||||
|
`awaitingConfirmation`; the new launcher confirms at its first managed
|
||||||
|
instruction, after which the helper releases its lease and the confirmed
|
||||||
|
launcher reclaims plan, data-transaction, and target-local residue. An
|
||||||
`applying` plan is rolled back before retry, and failure to start/confirm the
|
`applying` plan is rolled back before retry, and failure to start/confirm the
|
||||||
new launcher restores every original (and removes every no-original target).
|
new launcher restores every original (and removes every no-original target).
|
||||||
All plan paths are re-derived/contained under the pinned data root except the
|
Reading `pending.json` never performs cleanup. Ordinary startup attempts the
|
||||||
target directory, which must equal the actual launcher base directory.
|
exclusive lease without waiting and skips update cleanup entirely when another
|
||||||
|
session/staging transaction owns it. All plan paths are re-derived/contained
|
||||||
|
under pinned roots; the target directory must equal the actual launcher base
|
||||||
|
directory.
|
||||||
|
|
||||||
|
Every portable archive and persisted relative path rejects Windows device
|
||||||
|
segments on every host: `CON`, `PRN`, `AUX`, `NUL`, `CLOCK$`, `CONIN$`,
|
||||||
|
`CONOUT$`, `COM1`-`COM9`, `LPT1`-`LPT9`, and the Windows-equivalent superscript
|
||||||
|
forms `COM¹`/`COM²`/`COM³` and `LPT¹`/`LPT²`/`LPT³`, including extensions.
|
||||||
|
|
||||||
## LA11 — closeout
|
## LA11 — closeout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -298,19 +298,19 @@ preview would be a deliberate divergence we are NOT taking.
|
||||||
install to `DataDirectory/app/<version>/`; atomic pointer swap
|
install to `DataDirectory/app/<version>/`; atomic pointer swap
|
||||||
(`current.json`); never while any session is running; keep the previous
|
(`current.json`); never while any session is running; keep the previous
|
||||||
version for one-step rollback.
|
version for one-step rollback.
|
||||||
- **Launcher self-update:** same feed; staged download; rename-dance swap
|
- **Launcher self-update:** same feed; staged download; target-local atomic
|
||||||
on next start (a running exe can't replace itself on Windows).
|
replacement on next start after the running process exits.
|
||||||
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
|
- **Feed hosting:** GitHub Releases (user-confirmed). Manifest and zips
|
||||||
are release assets; the launcher pins the repo/owner in its config.
|
are release assets; the launcher pins the repo/owner in its config.
|
||||||
|
|
||||||
The exact v1 manifest, extracted-version record, `current.json` activation
|
The exact v1 manifest, extracted-version record, `current.json` activation
|
||||||
pointer, shared-session/exclusive-update OS lease, and durable self-update
|
pointer and launcher ownership record, shared-session/exclusive-update OS
|
||||||
plan are pinned in
|
lease, and durable self-update plan schema 2 are pinned in
|
||||||
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
|
`docs/plans/2026-08-14-launcher-campaign.md` under **Pinned updater
|
||||||
contracts (v1, BINDING)**. That section is normative: implementations reject
|
contracts (v1, BINDING)**. That section is normative: implementations reject
|
||||||
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
|
unknown/duplicate fields and unsupported versions, use strict SemVer 2.0
|
||||||
precedence, verify bounded streamed downloads before safe ZIP extraction, and
|
precedence, verify bounded streamed downloads before safe ZIP extraction, and
|
||||||
derive all mutable staging/backup paths from the application data root. The
|
use per-hop redirect validation plus same-filesystem atomic replacement. The
|
||||||
LA9 DAT/pak install record remains the sole content descriptor fed to session
|
LA9 DAT/pak install record remains the sole content descriptor fed to session
|
||||||
configs; LA10 changes only which verified `app/current.json` client binaries
|
configs; LA10 changes only which verified `app/current.json` client binaries
|
||||||
the process supervisor executes.
|
the process supervisor executes.
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ public sealed class LauncherProcessSupervisorFactory(
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5);
|
||||||
private readonly ILauncherChildProcessFactory _factory;
|
private readonly ILauncherChildProcessFactory _factory;
|
||||||
private readonly object _gate = new();
|
private readonly object _gate = new();
|
||||||
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
|
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
|
||||||
|
|
@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
private LauncherSessionState _state = LauncherSessionState.Starting;
|
private LauncherSessionState _state = LauncherSessionState.Starting;
|
||||||
private int? _exitCode;
|
private int? _exitCode;
|
||||||
private bool _publishingStateChanges;
|
private bool _publishingStateChanges;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
|
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
|
||||||
{
|
{
|
||||||
|
|
@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
ILauncherChildProcess process;
|
ILauncherChildProcess process;
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
if (_process is not null)
|
if (_process is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
|
|
@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
if (!process.WaitForExit(timeout) && !process.HasExited)
|
if (!process.WaitForExit(timeout) && !process.HasExited)
|
||||||
{
|
{
|
||||||
process.Kill();
|
process.Kill();
|
||||||
|
if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The launcher child could not be observed terminal after it was killed.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,6 +315,23 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
ILauncherChildProcess? process;
|
||||||
|
lock (_gate)
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_disposed = true;
|
||||||
|
process = _process;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process is { HasExited: false })
|
||||||
|
{
|
||||||
|
Stop(DisposeStopTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
lock (_gate)
|
lock (_gate)
|
||||||
{
|
{
|
||||||
if (_process is not null)
|
if (_process is not null)
|
||||||
|
|
|
||||||
|
|
@ -149,6 +149,13 @@ public sealed class LauncherExecutableSet
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static LauncherExecutableSet Unavailable(string reason)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||||
|
return new LauncherExecutableSet(
|
||||||
|
() => throw new LauncherUpdateException(reason));
|
||||||
|
}
|
||||||
|
|
||||||
private ExecutablePaths RequireAvailable(LaunchMode mode)
|
private ExecutablePaths RequireAvailable(LaunchMode mode)
|
||||||
{
|
{
|
||||||
LauncherCapability capability = GetAvailability(mode);
|
LauncherCapability capability = GetAvailability(mode);
|
||||||
|
|
|
||||||
|
|
@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
}
|
}
|
||||||
|
|
||||||
request.Cancellation.Dispose();
|
request.Cancellation.Dispose();
|
||||||
|
request.Activity.StartCompleted.Set();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
private static void DisposeActivity(ManagedActivity activity)
|
private static void DisposeActivity(ManagedActivity activity)
|
||||||
{
|
{
|
||||||
activity.StartCancellation?.Cancel();
|
activity.StartCancellation?.Cancel();
|
||||||
|
activity.StartCompleted.Wait();
|
||||||
activity.StartCancellation?.Dispose();
|
activity.StartCancellation?.Dispose();
|
||||||
activity.StartCancellation = null;
|
activity.StartCancellation = null;
|
||||||
|
|
||||||
if (activity.Supervisor is not null)
|
if (activity.Supervisor is not null)
|
||||||
{
|
{
|
||||||
|
// Disposal is a process-lifetime transaction: the shared update
|
||||||
|
// lease remains held until Stop has observed the real child
|
||||||
|
// terminal (including the post-kill wait).
|
||||||
|
activity.Supervisor.Stop(TimeSpan.FromSeconds(5));
|
||||||
if (activity.SupervisorStateHandler is not null)
|
if (activity.SupervisorStateHandler is not null)
|
||||||
{
|
{
|
||||||
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
|
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
|
||||||
|
|
@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
}
|
}
|
||||||
|
|
||||||
ReleaseUpdateSessionLease(activity);
|
ReleaseUpdateSessionLease(activity);
|
||||||
|
activity.StartCompleted.Dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
|
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
|
||||||
|
|
@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
||||||
|
|
||||||
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
|
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
|
||||||
|
|
||||||
|
public ManualResetEventSlim StartCompleted { get; } = new(false);
|
||||||
|
|
||||||
public object StatusReadGate { get; } = new();
|
public object StatusReadGate { get; } = new();
|
||||||
|
|
||||||
public bool IsActive => State is not (
|
public bool IsActive => State is not (
|
||||||
|
|
|
||||||
|
|
@ -503,7 +503,9 @@ public sealed class ClientVersionStore
|
||||||
.Where(path => !string.Equals(
|
.Where(path => !string.Equals(
|
||||||
path,
|
path,
|
||||||
"install.json",
|
"install.json",
|
||||||
StringComparison.OrdinalIgnoreCase))
|
OperatingSystem.IsWindows()
|
||||||
|
? StringComparison.OrdinalIgnoreCase
|
||||||
|
: StringComparison.Ordinal))
|
||||||
.OrderBy(path => path, StringComparer.Ordinal)
|
.OrderBy(path => path, StringComparer.Ordinal)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
string[] recordedFiles = record.Files
|
string[] recordedFiles = record.Files
|
||||||
|
|
@ -809,13 +811,43 @@ public sealed class ClientVersionStore
|
||||||
".client-staging-*",
|
".client-staging-*",
|
||||||
SearchOption.TopDirectoryOnly))
|
SearchOption.TopDirectoryOnly))
|
||||||
{
|
{
|
||||||
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
|
if (HasCanonicalGuidName(
|
||||||
if (Guid.TryParseExact(suffix, "N", out _))
|
Path.GetFileName(path),
|
||||||
|
".client-staging-",
|
||||||
|
string.Empty))
|
||||||
{
|
{
|
||||||
SafeZipExtractor.TryDeleteDirectory(path);
|
SafeZipExtractor.TryDeleteDirectory(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach (string path in Directory.EnumerateDirectories(
|
||||||
|
AppDirectory,
|
||||||
|
".client-corrupt-*",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
if (HasCanonicalGuidName(
|
||||||
|
Path.GetFileName(path),
|
||||||
|
".client-corrupt-",
|
||||||
|
string.Empty))
|
||||||
|
{
|
||||||
|
SafeZipExtractor.TryDeleteDirectory(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (string path in Directory.EnumerateFiles(
|
||||||
|
AppDirectory,
|
||||||
|
".client-download-*.zip",
|
||||||
|
SearchOption.TopDirectoryOnly))
|
||||||
|
{
|
||||||
|
if (HasCanonicalGuidName(
|
||||||
|
Path.GetFileName(path),
|
||||||
|
".client-download-",
|
||||||
|
".zip"))
|
||||||
|
{
|
||||||
|
VerifiedArtifactDownloader.TryDelete(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
foreach (string path in Directory.EnumerateFiles(
|
foreach (string path in Directory.EnumerateFiles(
|
||||||
AppDirectory,
|
AppDirectory,
|
||||||
".current*.tmp",
|
".current*.tmp",
|
||||||
|
|
@ -825,20 +857,43 @@ public sealed class ClientVersionStore
|
||||||
string[] parts = fileName.Split('.');
|
string[] parts = fileName.Split('.');
|
||||||
if (parts.Length >= 4
|
if (parts.Length >= 4
|
||||||
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
|
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
|
||||||
&& Guid.TryParseExact(parts[^2], "N", out _))
|
&& Guid.TryParseExact(parts[^2], "N", out Guid parsed)
|
||||||
|
&& string.Equals(
|
||||||
|
parsed.ToString("N"),
|
||||||
|
parts[^2],
|
||||||
|
StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
VerifiedArtifactDownloader.TryDelete(path);
|
VerifiedArtifactDownloader.TryDelete(path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static bool HasCanonicalGuidName(
|
||||||
|
string fileName,
|
||||||
|
string prefix,
|
||||||
|
string suffix)
|
||||||
|
{
|
||||||
|
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|
||||||
|
|| !fileName.EndsWith(suffix, StringComparison.Ordinal)
|
||||||
|
|| fileName.Length != prefix.Length + 32 + suffix.Length)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
string value = fileName.Substring(prefix.Length, 32);
|
||||||
|
return Guid.TryParseExact(value, "N", out Guid parsed)
|
||||||
|
&& string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
private void RequireOwnedStagingPath(string path)
|
private void RequireOwnedStagingPath(string path)
|
||||||
{
|
{
|
||||||
string parent = Path.GetDirectoryName(path) ?? string.Empty;
|
string parent = Path.GetDirectoryName(path) ?? string.Empty;
|
||||||
string fileName = Path.GetFileName(path);
|
string fileName = Path.GetFileName(path);
|
||||||
if (!PathsEqual(parent, AppDirectory)
|
if (!PathsEqual(parent, AppDirectory)
|
||||||
|| !fileName.StartsWith(".client-staging-", StringComparison.Ordinal)
|
|| !HasCanonicalGuidName(
|
||||||
|| !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _))
|
fileName,
|
||||||
|
".client-staging-",
|
||||||
|
string.Empty))
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
"The client extraction path is not an owned LA10 staging directory.");
|
"The client extraction path is not an owned LA10 staging directory.");
|
||||||
|
|
@ -901,7 +956,7 @@ public sealed class ClientVersionStore
|
||||||
&& !part.Any(character =>
|
&& !part.Any(character =>
|
||||||
char.IsControl(character)
|
char.IsControl(character)
|
||||||
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||||
&& !IsWindowsDeviceName(part));
|
&& !PortablePathRules.IsWindowsDeviceName(part));
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string ResolveContained(string root, string relative)
|
internal static string ResolveContained(string root, string relative)
|
||||||
|
|
@ -929,19 +984,6 @@ public sealed class ClientVersionStore
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsWindowsDeviceName(string segment)
|
|
||||||
{
|
|
||||||
string stem = segment.Split('.')[0];
|
|
||||||
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| (stem.Length == 4
|
|
||||||
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
|
|
||||||
&& stem[3] is >= '1' and <= '9');
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool PathsEqual(string left, string right) =>
|
private static bool PathsEqual(string left, string right) =>
|
||||||
string.Equals(
|
string.Equals(
|
||||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||||
|
|
|
||||||
|
|
@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult(
|
||||||
string[] RemainingArguments);
|
string[] RemainingArguments);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Process-level rename dance for launcher self-update. Every child argument
|
/// Process-level self-update bootstrap. Every child argument is passed through
|
||||||
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with
|
/// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
|
||||||
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
|
|
||||||
/// shell command.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static class LauncherSelfUpdateBootstrap
|
public static class LauncherSelfUpdateBootstrap
|
||||||
{
|
{
|
||||||
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
||||||
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
||||||
|
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
|
||||||
|
internal const int DeferredLeaseExitCode = 73;
|
||||||
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
||||||
|
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
|
||||||
|
|
||||||
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
|
|
@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
Path.GetFullPath(launcherBaseDirectory));
|
Path.GetFullPath(launcherBaseDirectory));
|
||||||
string executable = Path.GetFullPath(currentExecutablePath);
|
string executable = Path.GetFullPath(currentExecutablePath);
|
||||||
|
|
||||||
|
if (args.Length > 0
|
||||||
|
&& string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args[1..]);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.Length > 0
|
if (args.Length > 0
|
||||||
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
if (args.Length != 4
|
if (args.Length < 4
|
||||||
|| !int.TryParse(
|
|| !int.TryParse(
|
||||||
args[1],
|
args[1],
|
||||||
System.Globalization.NumberStyles.None,
|
System.Globalization.NumberStyles.None,
|
||||||
|
|
@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
parentPid,
|
parentPid,
|
||||||
args[2],
|
args[2],
|
||||||
args[3],
|
args[3],
|
||||||
|
args[4..],
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return new SelfUpdateStartupResult(true, exitCode, []);
|
return new SelfUpdateStartupResult(true, exitCode, []);
|
||||||
|
|
@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
if (args.Length > 0
|
if (args.Length > 0
|
||||||
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
|
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
|
||||||
{
|
{
|
||||||
if (args.Length != 2)
|
if (args.Length < 2)
|
||||||
{
|
{
|
||||||
return new SelfUpdateStartupResult(true, 64, []);
|
return new SelfUpdateStartupResult(true, 64, []);
|
||||||
}
|
}
|
||||||
|
|
@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
executable,
|
executable,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
return new SelfUpdateStartupResult(false, 0, []);
|
await FinishConfirmedCleanupAsync(
|
||||||
}
|
manager,
|
||||||
|
|
||||||
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (plan is null)
|
|
||||||
{
|
|
||||||
return new SelfUpdateStartupResult(false, 0, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
|
||||||
{
|
|
||||||
throw new LauncherUpdateException(
|
|
||||||
"The pending self-update targets a different launcher directory.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
|
||||||
{
|
|
||||||
if (!manager.IsConfirmed(plan.TransactionId))
|
|
||||||
{
|
|
||||||
await manager.ConfirmAsync(
|
|
||||||
plan.TransactionId,
|
|
||||||
baseDirectory,
|
|
||||||
executable,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.CompleteConfirmedAsync(
|
|
||||||
plan.TransactionId,
|
|
||||||
baseDirectory,
|
baseDirectory,
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args[2..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!manager.Barrier.TryAcquireExclusive(
|
||||||
|
out UpdateSessionBarrier.ExclusiveLease? startupLease))
|
||||||
|
{
|
||||||
|
// A running session or another launcher is staging. Reading the
|
||||||
|
// plan is safe, but cleanup or starting a competing helper is not.
|
||||||
return new SelfUpdateStartupResult(false, 0, args);
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
|
||||||
? ".exe"
|
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
|
||||||
: string.Empty;
|
|
||||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
|
||||||
baseDirectory,
|
|
||||||
"acdream-launcher" + suffix);
|
|
||||||
if (!PathsEqual(executable, expectedExecutable))
|
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
|
||||||
"Self-update can start only from the published acdream-launcher executable.");
|
.ConfigureAwait(false);
|
||||||
}
|
_ = manager.CleanupOwnedResidueUnderLease(
|
||||||
|
plan,
|
||||||
|
baseDirectory,
|
||||||
|
lease);
|
||||||
|
if (plan is null)
|
||||||
|
{
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
|
}
|
||||||
|
|
||||||
string helperPath = manager.GetHelperPath(plan.TransactionId);
|
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!);
|
{
|
||||||
VerifiedArtifactDownloader.TryDelete(helperPath);
|
throw new LauncherUpdateException(
|
||||||
File.Copy(executable, helperPath, overwrite: false);
|
"The pending self-update targets a different launcher directory.");
|
||||||
if (OperatingSystem.IsLinux())
|
}
|
||||||
{
|
|
||||||
File.SetUnixFileMode(
|
|
||||||
helperPath,
|
|
||||||
UnixFileMode.UserRead
|
|
||||||
| UnixFileMode.UserWrite
|
|
||||||
| UnixFileMode.UserExecute);
|
|
||||||
}
|
|
||||||
|
|
||||||
var startInfo = new ProcessStartInfo(helperPath)
|
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
if (!manager.IsConfirmed(plan.TransactionId))
|
||||||
WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId),
|
{
|
||||||
};
|
await manager.ConfirmAsync(
|
||||||
startInfo.ArgumentList.Add(HelperArgument);
|
plan.TransactionId,
|
||||||
startInfo.ArgumentList.Add(
|
baseDirectory,
|
||||||
Environment.ProcessId.ToString(
|
executable,
|
||||||
System.Globalization.CultureInfo.InvariantCulture));
|
cancellationToken)
|
||||||
startInfo.ArgumentList.Add(baseDirectory);
|
.ConfigureAwait(false);
|
||||||
startInfo.ArgumentList.Add(plan.TransactionId);
|
}
|
||||||
_ = Process.Start(startInfo)
|
|
||||||
?? throw new LauncherUpdateException(
|
await manager.CompleteConfirmedAsync(
|
||||||
"The launcher self-update helper could not be started.");
|
plan.TransactionId,
|
||||||
return new SelfUpdateStartupResult(true, 0, []);
|
baseDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
_ = manager.CleanupOwnedResidueUnderLease(
|
||||||
|
pending: null,
|
||||||
|
baseDirectory,
|
||||||
|
lease);
|
||||||
|
return new SelfUpdateStartupResult(false, 0, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||||
|
baseDirectory,
|
||||||
|
GetLauncherFileName(plan.Rid));
|
||||||
|
if (!PathsEqual(executable, expectedExecutable))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"Self-update can start only from the published acdream-launcher executable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
string helperPath = manager.GetStagedLauncherPath(plan);
|
||||||
|
var startInfo = new ProcessStartInfo(helperPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId),
|
||||||
|
};
|
||||||
|
startInfo.ArgumentList.Add(HelperArgument);
|
||||||
|
startInfo.ArgumentList.Add(
|
||||||
|
Environment.ProcessId.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
startInfo.ArgumentList.Add(baseDirectory);
|
||||||
|
startInfo.ArgumentList.Add(plan.TransactionId);
|
||||||
|
foreach (string argument in args)
|
||||||
|
{
|
||||||
|
startInfo.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Process.Start(startInfo)
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
"The launcher self-update helper could not be started.");
|
||||||
|
return new SelfUpdateStartupResult(true, 0, []);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<int> RunHelperAsync(
|
private static async Task<int> RunHelperAsync(
|
||||||
|
|
@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
int parentPid,
|
int parentPid,
|
||||||
string targetDirectory,
|
string targetDirectory,
|
||||||
string transactionId,
|
string transactionId,
|
||||||
|
IReadOnlyList<string> publicArguments,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
||||||
|
|
@ -164,12 +188,15 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
"The helper transaction does not match the pending self-update.");
|
"The helper transaction does not match the pending self-update.");
|
||||||
}
|
}
|
||||||
|
|
||||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
|
||||||
? ".exe"
|
{
|
||||||
: string.Empty;
|
throw new LauncherUpdateException(
|
||||||
|
"The helper target does not match the pending self-update.");
|
||||||
|
}
|
||||||
|
|
||||||
string launcherPath = ClientVersionStore.ResolveContained(
|
string launcherPath = ClientVersionStore.ResolveContained(
|
||||||
targetDirectory,
|
targetDirectory,
|
||||||
"acdream-launcher" + suffix);
|
GetLauncherFileName(plan.Rid));
|
||||||
var startInfo = new ProcessStartInfo(launcherPath)
|
var startInfo = new ProcessStartInfo(launcherPath)
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
UseShellExecute = false,
|
||||||
|
|
@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap
|
||||||
};
|
};
|
||||||
startInfo.ArgumentList.Add(ConfirmArgument);
|
startInfo.ArgumentList.Add(ConfirmArgument);
|
||||||
startInfo.ArgumentList.Add(transactionId);
|
startInfo.ArgumentList.Add(transactionId);
|
||||||
|
foreach (string argument in publicArguments)
|
||||||
Process? replacement = null;
|
|
||||||
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
|
|
||||||
bool appliedByThisHelper = false;
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
|
startInfo.ArgumentList.Add(argument);
|
||||||
updateLease = manager.Barrier.AcquireExclusive();
|
|
||||||
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
appliedByThisHelper = true;
|
|
||||||
replacement = Process.Start(startInfo)
|
|
||||||
?? throw new LauncherUpdateException(
|
|
||||||
"The updated launcher could not be started.");
|
|
||||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
|
|
||||||
while (!manager.IsConfirmed(transactionId))
|
|
||||||
{
|
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
|
||||||
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
|
|
||||||
{
|
|
||||||
throw new LauncherUpdateException(
|
|
||||||
replacement.HasExited
|
|
||||||
? $"The updated launcher exited with code {replacement.ExitCode} "
|
|
||||||
+ "before confirming startup."
|
|
||||||
: "The updated launcher did not confirm startup in time.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
await manager.CompleteConfirmedAsync(
|
|
||||||
transactionId,
|
|
||||||
targetDirectory,
|
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
catch
|
|
||||||
|
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
|
||||||
|
if (!manager.Barrier.TryAcquireExclusive(
|
||||||
|
out UpdateSessionBarrier.ExclusiveLease? updateLease))
|
||||||
{
|
{
|
||||||
if (replacement is { HasExited: false })
|
// Do not restart the canonical launcher: it would immediately see
|
||||||
|
// the same staged plan and create an unbounded helper loop.
|
||||||
|
return DeferredLeaseExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
|
||||||
|
?? throw new InvalidOperationException("Exclusive update lease is missing."))
|
||||||
|
{
|
||||||
|
plan = await manager.LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false)
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
"The helper found no pending self-update after acquiring the lease.");
|
||||||
|
if (!string.Equals(
|
||||||
|
plan.TransactionId,
|
||||||
|
transactionId,
|
||||||
|
StringComparison.Ordinal)
|
||||||
|
|| !PathsEqual(plan.TargetDirectory, targetDirectory))
|
||||||
{
|
{
|
||||||
replacement.Kill(entireProcessTree: true);
|
throw new LauncherUpdateException(
|
||||||
await replacement.WaitForExitAsync(CancellationToken.None)
|
"The pending self-update changed before the helper acquired its lease.");
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_ = manager.CleanupOwnedResidueUnderLease(
|
||||||
|
plan,
|
||||||
|
targetDirectory,
|
||||||
|
lease);
|
||||||
|
Process? replacement = null;
|
||||||
|
bool appliedByThisHelper = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (appliedByThisHelper)
|
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
appliedByThisHelper = true;
|
||||||
|
replacement = Process.Start(startInfo)
|
||||||
|
?? throw new LauncherUpdateException(
|
||||||
|
"The updated launcher could not be started.");
|
||||||
|
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
|
||||||
|
while (!manager.IsConfirmed(transactionId))
|
||||||
{
|
{
|
||||||
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
CancellationToken.None)
|
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (pending?.State == SelfUpdatePlanState.Applying)
|
|
||||||
{
|
{
|
||||||
_ = await manager.RecoverApplyingAsync(
|
throw new LauncherUpdateException(
|
||||||
targetDirectory,
|
replacement.HasExited
|
||||||
CancellationToken.None)
|
? $"The updated launcher exited with code {replacement.ExitCode} "
|
||||||
.ConfigureAwait(false);
|
+ "before confirming startup."
|
||||||
}
|
: "The updated launcher did not confirm startup in time.");
|
||||||
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
|
|
||||||
{
|
|
||||||
_ = await manager.RollbackAwaitingConfirmationAsync(
|
|
||||||
targetDirectory,
|
|
||||||
CancellationToken.None)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await manager.CompleteConfirmedAsync(
|
||||||
|
transactionId,
|
||||||
|
targetDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
// Do not start an executable from an ambiguous half-applied
|
if (replacement is { HasExited: false })
|
||||||
// state. A subsequent startup replays the durable journal.
|
{
|
||||||
return 75;
|
replacement.Kill(entireProcessTree: true);
|
||||||
}
|
await replacement.WaitForExitAsync(CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
var restored = new ProcessStartInfo(launcherPath)
|
try
|
||||||
|
{
|
||||||
|
if (appliedByThisHelper)
|
||||||
|
{
|
||||||
|
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (pending?.State == SelfUpdatePlanState.Applying)
|
||||||
|
{
|
||||||
|
_ = await manager.RecoverApplyingAsync(
|
||||||
|
targetDirectory,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||||
|
{
|
||||||
|
_ = await manager.RollbackAwaitingConfirmationAsync(
|
||||||
|
targetDirectory,
|
||||||
|
CancellationToken.None)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// An ambiguous state must not start either executable.
|
||||||
|
return 75;
|
||||||
|
}
|
||||||
|
|
||||||
|
var restored = new ProcessStartInfo(launcherPath)
|
||||||
|
{
|
||||||
|
UseShellExecute = false,
|
||||||
|
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||||
|
};
|
||||||
|
restored.ArgumentList.Add(DeferredArgument);
|
||||||
|
foreach (string argument in publicArguments)
|
||||||
|
{
|
||||||
|
restored.ArgumentList.Add(argument);
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = Process.Start(restored);
|
||||||
|
return 74;
|
||||||
|
}
|
||||||
|
finally
|
||||||
{
|
{
|
||||||
UseShellExecute = false,
|
replacement?.Dispose();
|
||||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
}
|
||||||
};
|
|
||||||
_ = Process.Start(restored);
|
|
||||||
return 74;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
replacement?.Dispose();
|
|
||||||
updateLease?.Dispose();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task FinishConfirmedCleanupAsync(
|
||||||
|
LauncherSelfUpdateManager manager,
|
||||||
|
string targetDirectory,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
if (manager.Barrier.TryAcquireExclusive(
|
||||||
|
out UpdateSessionBarrier.ExclusiveLease? lease))
|
||||||
|
{
|
||||||
|
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"Exclusive cleanup lease is missing."))
|
||||||
|
{
|
||||||
|
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (pending is
|
||||||
|
{
|
||||||
|
State: SelfUpdatePlanState.AwaitingConfirmation,
|
||||||
|
}
|
||||||
|
&& manager.IsConfirmed(pending.TransactionId))
|
||||||
|
{
|
||||||
|
await manager.CompleteConfirmedAsync(
|
||||||
|
pending.TransactionId,
|
||||||
|
targetDirectory,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
pending = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (manager.CleanupOwnedResidueUnderLease(
|
||||||
|
pending,
|
||||||
|
targetDirectory,
|
||||||
|
acquiredLease))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
while (DateTimeOffset.UtcNow < deadline);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetLauncherFileName(string rid) =>
|
||||||
|
"acdream-launcher"
|
||||||
|
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
||||||
|
|
||||||
private static async Task WaitForParentExitAsync(
|
private static async Task WaitForParentExitAsync(
|
||||||
int parentPid,
|
int parentPid,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
RefuseRunningSessions();
|
RefuseRunningSessions();
|
||||||
using UpdateSessionBarrier.ExclusiveLease lease =
|
|
||||||
_versions.Barrier.AcquireExclusive();
|
|
||||||
RefuseRunningSessions();
|
|
||||||
if (check.Manifest.Version <= _launcherVersion)
|
if (check.Manifest.Version <= _launcherVersion)
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
|
|
|
||||||
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>
|
/// <summary>
|
||||||
/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is
|
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
||||||
/// accepted only for a loopback fixture; production and artifact URLs are
|
/// construction is HTTPS-only. The loopback HTTP allowance is available only
|
||||||
/// HTTPS-only.
|
/// through an internal fixture factory and is never inferred from a URI.
|
||||||
|
/// Redirects are followed manually so every hop is checked before any bytes
|
||||||
|
/// cross that hop.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
{
|
{
|
||||||
|
|
@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
public const string GitHubRepository = "acdream";
|
public const string GitHubRepository = "acdream";
|
||||||
public const int MaximumManifestBytes = 1024 * 1024;
|
public const int MaximumManifestBytes = 1024 * 1024;
|
||||||
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
|
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
|
||||||
|
public const int MaximumRedirects = 5;
|
||||||
|
|
||||||
public static Uri ProductionManifestUri { get; } = new(
|
public static Uri ProductionManifestUri { get; } = new(
|
||||||
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
|
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
|
||||||
|
|
@ -33,65 +36,103 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly HttpClient _httpClient;
|
private readonly HttpClient _httpClient;
|
||||||
private readonly bool _ownsHttpClient;
|
|
||||||
private readonly Uri _manifestUri;
|
private readonly Uri _manifestUri;
|
||||||
|
private readonly bool _allowLoopbackHttp;
|
||||||
|
|
||||||
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null)
|
public ReleaseManifestClient(TimeSpan? timeout = null)
|
||||||
|
: this(
|
||||||
|
ProductionManifestUri,
|
||||||
|
allowLoopbackHttp: false,
|
||||||
|
CreateRedirectDisabledHandler(),
|
||||||
|
timeout)
|
||||||
{
|
{
|
||||||
_httpClient = httpClient ?? new HttpClient();
|
|
||||||
_ownsHttpClient = httpClient is null;
|
|
||||||
_manifestUri = manifestUri ?? ProductionManifestUri;
|
|
||||||
RequireSecureOrLoopback(_manifestUri, "manifest");
|
|
||||||
if (_ownsHttpClient)
|
|
||||||
{
|
|
||||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private ReleaseManifestClient(
|
||||||
|
Uri manifestUri,
|
||||||
|
bool allowLoopbackHttp,
|
||||||
|
HttpMessageHandler handler,
|
||||||
|
TimeSpan? timeout)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(manifestUri);
|
||||||
|
ArgumentNullException.ThrowIfNull(handler);
|
||||||
|
_manifestUri = manifestUri;
|
||||||
|
_allowLoopbackHttp = allowLoopbackHttp;
|
||||||
|
RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp);
|
||||||
|
_httpClient = new HttpClient(handler, disposeHandler: true)
|
||||||
|
{
|
||||||
|
Timeout = timeout ?? TimeSpan.FromSeconds(15),
|
||||||
|
};
|
||||||
|
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static ReleaseManifestClient CreateLoopbackFixture(
|
||||||
|
Uri manifestUri,
|
||||||
|
TimeSpan? timeout = null) => new(
|
||||||
|
manifestUri,
|
||||||
|
allowLoopbackHttp: true,
|
||||||
|
CreateRedirectDisabledHandler(),
|
||||||
|
timeout);
|
||||||
|
|
||||||
|
internal static ReleaseManifestClient CreateForTransportTest(
|
||||||
|
Uri manifestUri,
|
||||||
|
bool allowLoopbackHttp,
|
||||||
|
HttpMessageHandler handler) => new(
|
||||||
|
manifestUri,
|
||||||
|
allowLoopbackHttp,
|
||||||
|
handler,
|
||||||
|
TimeSpan.FromSeconds(15));
|
||||||
|
|
||||||
public async Task<ReleaseManifest> FetchAsync(
|
public async Task<ReleaseManifest> FetchAsync(
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
Uri current = _manifestUri;
|
||||||
_manifestUri,
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
for (int redirectCount = 0;;)
|
||||||
cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri;
|
|
||||||
RequireSecureOrLoopback(finalUri, "manifest redirect");
|
|
||||||
if (response.Content.Headers.ContentLength is long contentLength
|
|
||||||
&& contentLength > MaximumManifestBytes)
|
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
RequireTransport(current, "manifest redirect", _allowLoopbackHttp);
|
||||||
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
if (!visited.Add(current.AbsoluteUri))
|
||||||
}
|
|
||||||
|
|
||||||
await using Stream input = await response.Content
|
|
||||||
.ReadAsStreamAsync(cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
using var output = new MemoryStream();
|
|
||||||
byte[] buffer = new byte[16 * 1024];
|
|
||||||
while (true)
|
|
||||||
{
|
|
||||||
int read = await input.ReadAsync(buffer, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
if (read == 0)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (output.Length + read > MaximumManifestBytes)
|
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
"The release manifest redirect chain contains a loop.");
|
||||||
}
|
}
|
||||||
|
|
||||||
output.Write(buffer, 0, read);
|
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||||
}
|
using HttpResponseMessage response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (IsRedirect(response.StatusCode))
|
||||||
|
{
|
||||||
|
if (redirectCount >= MaximumRedirects)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest exceeded {MaximumRedirects} redirects.");
|
||||||
|
}
|
||||||
|
|
||||||
return Parse(output.ToArray());
|
Uri? location = response.Headers.Location;
|
||||||
|
if (location is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The release manifest redirect has no Location header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri next = location.IsAbsoluteUri
|
||||||
|
? location
|
||||||
|
: new Uri(current, location);
|
||||||
|
RequireTransport(next, "manifest redirect", _allowLoopbackHttp);
|
||||||
|
current = next;
|
||||||
|
redirectCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
return await ReadAndParseAsync(response, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (OperationCanceledException)
|
||||||
{
|
{
|
||||||
|
|
@ -112,7 +153,9 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8)
|
internal static ReleaseManifest Parse(
|
||||||
|
ReadOnlySpan<byte> utf8,
|
||||||
|
bool allowLoopbackHttpArtifacts = false)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
RejectDuplicateProperties(document.RootElement, "$" );
|
RejectDuplicateProperties(document.RootElement, "$" );
|
||||||
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
|
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
|
||||||
SerializerOptions);
|
SerializerOptions);
|
||||||
return Validate(value);
|
return Validate(value, allowLoopbackHttpArtifacts);
|
||||||
}
|
}
|
||||||
catch (LauncherUpdateException)
|
catch (LauncherUpdateException)
|
||||||
{
|
{
|
||||||
|
|
@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose() => _httpClient.Dispose();
|
||||||
{
|
|
||||||
if (_ownsHttpClient)
|
|
||||||
{
|
|
||||||
_httpClient.Dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void RequireSecureOrLoopback(Uri uri, string description)
|
internal static void RequireTransport(
|
||||||
|
Uri uri,
|
||||||
|
string description,
|
||||||
|
bool allowLoopbackHttp)
|
||||||
{
|
{
|
||||||
if (!uri.IsAbsoluteUri
|
if (!uri.IsAbsoluteUri
|
||||||
|| (uri.Scheme != Uri.UriSchemeHttps
|
|| (uri.Scheme != Uri.UriSchemeHttps
|
||||||
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
&& !(allowLoopbackHttp
|
||||||
|
&& uri.Scheme == Uri.UriSchemeHttp
|
||||||
|
&& uri.IsLoopback)))
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
$"The {description} URI must use HTTPS (loopback HTTP is test-only).");
|
$"The {description} URI must use HTTPS"
|
||||||
|
+ (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : "."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ReleaseManifest Validate(ManifestDocument? document)
|
internal static void RequireSecureOrLoopback(Uri uri, string description) =>
|
||||||
|
RequireTransport(uri, description, allowLoopbackHttp: true);
|
||||||
|
|
||||||
|
private async Task<ReleaseManifest> ReadAndParseAsync(
|
||||||
|
HttpResponseMessage response,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (response.Content.Headers.ContentLength is long contentLength
|
||||||
|
&& contentLength > MaximumManifestBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await using Stream input = await response.Content
|
||||||
|
.ReadAsStreamAsync(cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
using var output = new MemoryStream();
|
||||||
|
byte[] buffer = new byte[16 * 1024];
|
||||||
|
while (true)
|
||||||
|
{
|
||||||
|
int read = await input.ReadAsync(buffer, cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
if (read == 0)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (output.Length + read > MaximumManifestBytes)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
output.Write(buffer, 0, read);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Parse(output.ToArray(), _allowLoopbackHttp);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ReleaseManifest Validate(
|
||||||
|
ManifestDocument? document,
|
||||||
|
bool allowLoopbackHttpArtifacts)
|
||||||
{
|
{
|
||||||
if (document is null)
|
if (document is null)
|
||||||
{
|
{
|
||||||
|
|
@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
"The minimum launcher version cannot exceed the release version.");
|
"The minimum launcher version cannot exceed the release version.");
|
||||||
}
|
}
|
||||||
|
|
||||||
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
|
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
|
||||||
document.Clients,
|
document.Clients,
|
||||||
"clients");
|
"clients",
|
||||||
|
allowLoopbackHttpArtifacts);
|
||||||
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
|
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
|
||||||
document.Launchers,
|
document.Launchers,
|
||||||
"launchers");
|
"launchers",
|
||||||
|
allowLoopbackHttpArtifacts);
|
||||||
return new ReleaseManifest(version, minimum, clients, launchers);
|
return new ReleaseManifest(version, minimum, clients, launchers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
|
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
|
||||||
Dictionary<string, ArtifactDocument>? artifacts,
|
Dictionary<string, ArtifactDocument>? artifacts,
|
||||||
string field)
|
string field,
|
||||||
|
bool allowLoopbackHttpArtifacts)
|
||||||
{
|
{
|
||||||
if (artifacts is null || artifacts.Count == 0)
|
if (artifacts is null || artifacts.Count == 0)
|
||||||
{
|
{
|
||||||
|
|
@ -226,7 +315,10 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
$"Manifest payload '{field}.{rid}' has an invalid URL.");
|
$"Manifest payload '{field}.{rid}' has an invalid URL.");
|
||||||
}
|
}
|
||||||
|
|
||||||
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
|
RequireTransport(
|
||||||
|
uri,
|
||||||
|
$"{field}.{rid} artifact",
|
||||||
|
allowLoopbackHttpArtifacts);
|
||||||
if (!IsSha256(value.Sha256))
|
if (!IsSha256(value.Sha256))
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
|
|
@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||||
internal static bool IsSha256(string? value) =>
|
internal static bool IsSha256(string? value) =>
|
||||||
value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||||
|
|
||||||
|
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
|
||||||
|
HttpStatusCode.MovedPermanently
|
||||||
|
or HttpStatusCode.Found
|
||||||
|
or HttpStatusCode.SeeOther
|
||||||
|
or HttpStatusCode.TemporaryRedirect
|
||||||
|
or HttpStatusCode.PermanentRedirect;
|
||||||
|
|
||||||
|
private static HttpMessageHandler CreateRedirectDisabledHandler() =>
|
||||||
|
new HttpClientHandler
|
||||||
|
{
|
||||||
|
AllowAutoRedirect = false,
|
||||||
|
UseCookies = false,
|
||||||
|
AutomaticDecompression = DecompressionMethods.None,
|
||||||
|
};
|
||||||
|
|
||||||
private static void RejectDuplicateProperties(JsonElement element, string path)
|
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||||
{
|
{
|
||||||
if (element.ValueKind == JsonValueKind.Object)
|
if (element.ValueKind == JsonValueKind.Object)
|
||||||
|
|
|
||||||
|
|
@ -297,7 +297,7 @@ public sealed class SafeZipExtractor
|
||||||
|| segment.Any(character =>
|
|| segment.Any(character =>
|
||||||
char.IsControl(character)
|
char.IsControl(character)
|
||||||
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||||
|| IsWindowsDeviceName(segment))
|
|| PortablePathRules.IsWindowsDeviceName(segment))
|
||||||
{
|
{
|
||||||
throw new LauncherUpdateException(
|
throw new LauncherUpdateException(
|
||||||
$"ZIP path '{name}' contains an unsafe segment.");
|
$"ZIP path '{name}' contains an unsafe segment.");
|
||||||
|
|
@ -396,19 +396,6 @@ public sealed class SafeZipExtractor
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool IsWindowsDeviceName(string segment)
|
|
||||||
{
|
|
||||||
string stem = segment.Split('.')[0];
|
|
||||||
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| (stem.Length == 4
|
|
||||||
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
|
||||||
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
|
|
||||||
&& stem[3] is >= '1' and <= '9');
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static void TryDeleteDirectory(string path)
|
internal static void TryDeleteDirectory(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier
|
||||||
FileShare.None,
|
FileShare.None,
|
||||||
"A launcher session or another update transaction is running. "
|
"A launcher session or another update transaction is running. "
|
||||||
+ "Stop every launcher session before updating.");
|
+ "Stop every launcher session before updating.");
|
||||||
return new ExclusiveLease(stream);
|
return new ExclusiveLease(this, stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Non-blocking startup probe. Contention is an expected "not now"
|
||||||
|
/// result; permission and path failures remain hard errors.
|
||||||
|
/// </summary>
|
||||||
|
public bool TryAcquireExclusive(out ExclusiveLease? lease)
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(
|
||||||
|
Path.GetDirectoryName(_lockPath)
|
||||||
|
?? throw new InvalidOperationException(
|
||||||
|
"The update/session lock path has no parent directory."));
|
||||||
|
try
|
||||||
|
{
|
||||||
|
lease = new ExclusiveLease(
|
||||||
|
this,
|
||||||
|
new FileStream(
|
||||||
|
_lockPath,
|
||||||
|
FileMode.OpenOrCreate,
|
||||||
|
FileAccess.ReadWrite,
|
||||||
|
FileShare.None,
|
||||||
|
bufferSize: 1,
|
||||||
|
FileOptions.None));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
lease = null;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException ex)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The update/session lease could not be opened: {ex.Message}",
|
||||||
|
ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal void RequireOwned(ExclusiveLease lease)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(lease);
|
||||||
|
if (!lease.IsHeldBy(this))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The cleanup operation does not hold this update barrier's exclusive lease.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private FileStream Open(FileShare share, string refusal)
|
private FileStream Open(FileShare share, string refusal)
|
||||||
|
|
@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier
|
||||||
|
|
||||||
public sealed class ExclusiveLease : IDisposable
|
public sealed class ExclusiveLease : IDisposable
|
||||||
{
|
{
|
||||||
|
private readonly UpdateSessionBarrier _owner;
|
||||||
private FileStream? _stream;
|
private FileStream? _stream;
|
||||||
|
|
||||||
internal ExclusiveLease(FileStream stream) => _stream = stream;
|
internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream)
|
||||||
|
{
|
||||||
|
_owner = owner;
|
||||||
|
_stream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
internal bool IsHeldBy(UpdateSessionBarrier owner) =>
|
||||||
|
ReferenceEquals(_owner, owner)
|
||||||
|
&& Volatile.Read(ref _stream) is not null;
|
||||||
|
|
||||||
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Buffers;
|
using System.Buffers;
|
||||||
|
using System.Net;
|
||||||
using System.Security.Cryptography;
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Updates;
|
namespace AcDream.Launcher.Core.Updates;
|
||||||
|
|
@ -55,15 +56,11 @@ public sealed class VerifiedArtifactDownloader
|
||||||
bool ownsDestination = false;
|
bool ownsDestination = false;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
using HttpResponseMessage response = await SendWithValidatedRedirectsAsync(
|
||||||
artifact.Url,
|
artifact.Url,
|
||||||
HttpCompletionOption.ResponseHeadersRead,
|
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
ReleaseManifestClient.RequireSecureOrLoopback(
|
|
||||||
response.RequestMessage?.RequestUri ?? artifact.Url,
|
|
||||||
"artifact redirect");
|
|
||||||
if (response.Content.Headers.ContentLength is long contentLength
|
if (response.Content.Headers.ContentLength is long contentLength
|
||||||
&& contentLength != artifact.Size)
|
&& contentLength != artifact.Size)
|
||||||
{
|
{
|
||||||
|
|
@ -183,6 +180,86 @@ public sealed class VerifiedArtifactDownloader
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<HttpResponseMessage> SendWithValidatedRedirectsAsync(
|
||||||
|
Uri initialUri,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp
|
||||||
|
&& initialUri.IsLoopback;
|
||||||
|
Uri current = initialUri;
|
||||||
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
for (int redirectCount = 0;;)
|
||||||
|
{
|
||||||
|
ReleaseManifestClient.RequireTransport(
|
||||||
|
current,
|
||||||
|
"artifact redirect",
|
||||||
|
allowLoopbackHttp);
|
||||||
|
if (!visited.Add(current.AbsoluteUri))
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The release artifact redirect chain contains a loop.");
|
||||||
|
}
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||||
|
HttpResponseMessage response = await _httpClient.SendAsync(
|
||||||
|
request,
|
||||||
|
HttpCompletionOption.ResponseHeadersRead,
|
||||||
|
cancellationToken)
|
||||||
|
.ConfigureAwait(false);
|
||||||
|
Uri effectiveUri = response.RequestMessage?.RequestUri ?? current;
|
||||||
|
if (!Uri.Equals(effectiveUri, current))
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The artifact HTTP transport followed an automatic redirect; "
|
||||||
|
+ "every redirect must be validated before it is requested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!IsRedirect(response.StatusCode))
|
||||||
|
{
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (redirectCount >= ReleaseManifestClient.MaximumRedirects)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
$"The release artifact exceeded "
|
||||||
|
+ $"{ReleaseManifestClient.MaximumRedirects} redirects.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri? location = response.Headers.Location;
|
||||||
|
if (location is null)
|
||||||
|
{
|
||||||
|
throw new LauncherUpdateException(
|
||||||
|
"The release artifact redirect has no Location header.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri next = location.IsAbsoluteUri
|
||||||
|
? location
|
||||||
|
: new Uri(current, location);
|
||||||
|
ReleaseManifestClient.RequireTransport(
|
||||||
|
next,
|
||||||
|
"artifact redirect",
|
||||||
|
allowLoopbackHttp);
|
||||||
|
current = next;
|
||||||
|
redirectCount++;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
response.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
|
||||||
|
HttpStatusCode.MovedPermanently
|
||||||
|
or HttpStatusCode.Found
|
||||||
|
or HttpStatusCode.SeeOther
|
||||||
|
or HttpStatusCode.TemporaryRedirect
|
||||||
|
or HttpStatusCode.PermanentRedirect;
|
||||||
|
|
||||||
internal static void TryDelete(string path)
|
internal static void TryDelete(string path)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,10 @@
|
||||||
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
|
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,7 @@ public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
private LauncherOrchestrator? _orchestrator;
|
private LauncherOrchestrator? _orchestrator;
|
||||||
private LauncherWindowViewModel? _viewModel;
|
private LauncherWindowViewModel? _viewModel;
|
||||||
private HttpClient? _updateHttpClient;
|
private LauncherUpdateComposition? _updateComposition;
|
||||||
private ReleaseManifestClient? _manifestClient;
|
|
||||||
|
|
||||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||||
|
|
||||||
|
|
@ -52,38 +51,27 @@ public sealed partial class App : Application
|
||||||
$"Client content verification failed: {ex.Message}");
|
$"Client content verification failed: {ex.Message}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientVersions = new ClientVersionStore(paths);
|
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
|
||||||
_ = clientVersions.LoadAndRecoverAsync(rid)
|
paths,
|
||||||
.GetAwaiter()
|
rid,
|
||||||
.GetResult();
|
GetLauncherVersion(),
|
||||||
|
AppContext.BaseDirectory,
|
||||||
|
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
||||||
|
== true);
|
||||||
|
_updateComposition = updates;
|
||||||
|
|
||||||
_orchestrator = new LauncherOrchestrator(
|
_orchestrator = new LauncherOrchestrator(
|
||||||
profiles,
|
profiles,
|
||||||
paths,
|
paths,
|
||||||
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
|
updates.Executables,
|
||||||
verification.Record,
|
verification.Record,
|
||||||
installationStatus: verification.Status,
|
installationStatus: verification.Status,
|
||||||
updateSessionBarrier: clientVersions.Barrier);
|
updateSessionBarrier: updates.Versions.Barrier);
|
||||||
_updateHttpClient = new HttpClient();
|
|
||||||
_updateHttpClient.Timeout = TimeSpan.FromSeconds(15);
|
|
||||||
_updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
|
|
||||||
"acdream-launcher/1");
|
|
||||||
_manifestClient = new ReleaseManifestClient(_updateHttpClient);
|
|
||||||
var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient);
|
|
||||||
var updater = new LauncherUpdater(
|
|
||||||
_manifestClient,
|
|
||||||
_updateHttpClient,
|
|
||||||
clientVersions,
|
|
||||||
selfUpdates,
|
|
||||||
GetLauncherVersion(),
|
|
||||||
rid,
|
|
||||||
AppContext.BaseDirectory,
|
|
||||||
() => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive));
|
|
||||||
_viewModel = new LauncherWindowViewModel(
|
_viewModel = new LauncherWindowViewModel(
|
||||||
_orchestrator,
|
_orchestrator,
|
||||||
new AvaloniaUiDispatcher(),
|
new AvaloniaUiDispatcher(),
|
||||||
installer,
|
installer,
|
||||||
updater);
|
updates.Updater);
|
||||||
_viewModel.Initialize();
|
_viewModel.Initialize();
|
||||||
|
|
||||||
desktop.MainWindow = new MainWindow
|
desktop.MainWindow = new MainWindow
|
||||||
|
|
@ -100,12 +88,10 @@ public sealed partial class App : Application
|
||||||
{
|
{
|
||||||
_viewModel?.Dispose();
|
_viewModel?.Dispose();
|
||||||
_orchestrator?.Dispose();
|
_orchestrator?.Dispose();
|
||||||
_manifestClient?.Dispose();
|
_updateComposition?.Dispose();
|
||||||
_updateHttpClient?.Dispose();
|
|
||||||
_viewModel = null;
|
_viewModel = null;
|
||||||
_orchestrator = null;
|
_orchestrator = null;
|
||||||
_manifestClient = null;
|
_updateComposition = null;
|
||||||
_updateHttpClient = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LauncherVersion GetLauncherVersion()
|
private static LauncherVersion GetLauncherVersion()
|
||||||
|
|
|
||||||
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
|
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
|
||||||
{
|
{
|
||||||
private static readonly ClientVersionResolution Missing = new(
|
private readonly ClientVersionResolution _resolution;
|
||||||
ClientVersionState.Missing,
|
private readonly string _status;
|
||||||
"Versioned client updater is unavailable.",
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null,
|
|
||||||
null);
|
|
||||||
|
|
||||||
public ClientVersionResolution CurrentClient => Missing;
|
public UnavailableLauncherUpdater(
|
||||||
|
string status = "Versioned client updater is unavailable.",
|
||||||
|
ClientVersionResolution? resolution = null)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(status);
|
||||||
|
_status = status;
|
||||||
|
_resolution = resolution ?? new ClientVersionResolution(
|
||||||
|
ClientVersionState.Invalid,
|
||||||
|
status,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ClientVersionResolution CurrentClient => _resolution;
|
||||||
|
|
||||||
public Task<ClientVersionResolution> InitializeAsync(
|
public Task<ClientVersionResolution> InitializeAsync(
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Task.FromResult(Missing);
|
Task.FromResult(_resolution);
|
||||||
|
|
||||||
public Task<LauncherUpdateCheckResult> CheckAsync(
|
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Task.FromException<LauncherUpdateCheckResult>(
|
Task.FromException<LauncherUpdateCheckResult>(
|
||||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
new LauncherUpdateException(_status));
|
||||||
|
|
||||||
public Task<ClientVersionResolution> InstallClientAsync(
|
public Task<ClientVersionResolution> InstallClientAsync(
|
||||||
LauncherUpdateCheckResult check,
|
LauncherUpdateCheckResult check,
|
||||||
IProgress<LauncherUpdateProgress>? progress = null,
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Task.FromException<ClientVersionResolution>(
|
Task.FromException<ClientVersionResolution>(
|
||||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
new LauncherUpdateException(_status));
|
||||||
|
|
||||||
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||||
LauncherUpdateCheckResult check,
|
LauncherUpdateCheckResult check,
|
||||||
IProgress<LauncherUpdateProgress>? progress = null,
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Task.FromException<SelfUpdateStageResult>(
|
Task.FromException<SelfUpdateStageResult>(
|
||||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
new LauncherUpdateException(_status));
|
||||||
|
|
||||||
public Task<ClientVersionResolution> RollbackClientAsync(
|
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||||
IProgress<LauncherUpdateProgress>? progress = null,
|
IProgress<LauncherUpdateProgress>? progress = null,
|
||||||
CancellationToken cancellationToken = default) =>
|
CancellationToken cancellationToken = default) =>
|
||||||
Task.FromException<ClientVersionResolution>(
|
Task.FromException<ClientVersionResolution>(
|
||||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
new LauncherUpdateException(_status));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,15 +5,176 @@ using AcDream.Launcher.Core.Installation;
|
||||||
using AcDream.Launcher.Core.Updates;
|
using AcDream.Launcher.Core.Updates;
|
||||||
using AcDream.Platform;
|
using AcDream.Platform;
|
||||||
|
|
||||||
return args.FirstOrDefault() switch
|
const string SelfUpdateDataEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_DATA";
|
||||||
|
const string SelfUpdateTargetEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_TARGET";
|
||||||
|
const string SelfUpdateHelperPidEnvironment = "ACDREAM_SELF_UPDATE_FIXTURE_HELPER_PID";
|
||||||
|
|
||||||
|
string[] effectiveArgs = args;
|
||||||
|
string? selfUpdateData = Environment.GetEnvironmentVariable(SelfUpdateDataEnvironment);
|
||||||
|
string? selfUpdateTarget = Environment.GetEnvironmentVariable(SelfUpdateTargetEnvironment);
|
||||||
|
if (!string.IsNullOrWhiteSpace(selfUpdateData)
|
||||||
|
&& !string.IsNullOrWhiteSpace(selfUpdateTarget)
|
||||||
|
&& IsBootstrapInvocation(effectiveArgs))
|
||||||
{
|
{
|
||||||
"hold-install-lease" => await HoldInstallLeaseAsync(args[1..]),
|
if (effectiveArgs[0] == LauncherSelfUpdateBootstrap.HelperArgument
|
||||||
"hold-update-lease" => await HoldUpdateLeaseAsync(args[1..]),
|
&& Environment.GetEnvironmentVariable(SelfUpdateHelperPidEnvironment) is string helperPid
|
||||||
"orphan-parent" => await RunOrphanParentAsync(args[1..]),
|
&& !string.IsNullOrWhiteSpace(helperPid))
|
||||||
"orphan-child" => RunOrphanChild(args[1..]),
|
{
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.GetFullPath(helperPid),
|
||||||
|
Environment.ProcessId.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
}
|
||||||
|
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var manager = new LauncherSelfUpdateManager(Paths(selfUpdateData), http);
|
||||||
|
SelfUpdateStartupResult startup = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
effectiveArgs,
|
||||||
|
manager,
|
||||||
|
Path.GetFullPath(selfUpdateTarget),
|
||||||
|
Path.GetFullPath(
|
||||||
|
Environment.ProcessPath
|
||||||
|
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||||
|
if (startup.ShouldExit)
|
||||||
|
{
|
||||||
|
return startup.ExitCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveArgs = startup.RemainingArguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effectiveArgs.FirstOrDefault() switch
|
||||||
|
{
|
||||||
|
"hold-install-lease" => await HoldInstallLeaseAsync(effectiveArgs[1..]),
|
||||||
|
"hold-update-lease" => await HoldUpdateLeaseAsync(effectiveArgs[1..]),
|
||||||
|
"orphan-parent" => await RunOrphanParentAsync(effectiveArgs[1..]),
|
||||||
|
"orphan-child" => RunOrphanChild(effectiveArgs[1..]),
|
||||||
|
"crash-self-update" => await CrashSelfUpdateAsync(effectiveArgs[1..]),
|
||||||
|
"stage-self-update" => await StageSelfUpdateAsync(effectiveArgs[1..]),
|
||||||
|
"bootstrap-probe" => await BootstrapProbeAsync(effectiveArgs[1..]),
|
||||||
|
"canonical-probe" => CanonicalProbe(effectiveArgs[1..]),
|
||||||
_ => 2,
|
_ => 2,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
static bool IsBootstrapInvocation(string[] arguments) =>
|
||||||
|
arguments.Length > 0
|
||||||
|
&& arguments[0] is LauncherSelfUpdateBootstrap.HelperArgument
|
||||||
|
or LauncherSelfUpdateBootstrap.ConfirmArgument
|
||||||
|
or LauncherSelfUpdateBootstrap.DeferredArgument
|
||||||
|
or "canonical-probe";
|
||||||
|
|
||||||
|
static ApplicationPathSet Paths(string dataDirectory)
|
||||||
|
{
|
||||||
|
string data = Path.GetFullPath(dataDirectory);
|
||||||
|
return new ApplicationPathSet(
|
||||||
|
Path.Combine(data, "fixture-config"),
|
||||||
|
data,
|
||||||
|
Path.Combine(data, "fixture-cache"),
|
||||||
|
null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<int> CrashSelfUpdateAsync(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 4)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||||
|
string targetDirectory = Path.GetFullPath(arguments[1]);
|
||||||
|
string readyPath = Path.GetFullPath(arguments[2]);
|
||||||
|
string canonicalName = arguments[3];
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var manager = new LauncherSelfUpdateManager(
|
||||||
|
Paths(dataDirectory),
|
||||||
|
http,
|
||||||
|
null,
|
||||||
|
observation =>
|
||||||
|
{
|
||||||
|
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
|
||||||
|
&& string.Equals(
|
||||||
|
observation.Path,
|
||||||
|
canonicalName,
|
||||||
|
StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
if (!File.Exists(Path.Combine(targetDirectory, canonicalName)))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"The canonical launcher vanished at the apply boundary.");
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(readyPath, Environment.ProcessId.ToString(
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture));
|
||||||
|
Thread.Sleep(Timeout.Infinite);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease lease = manager.Barrier.AcquireExclusive();
|
||||||
|
_ = await manager.ApplyPendingAsync(targetDirectory);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<int> StageSelfUpdateAsync(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 7
|
||||||
|
|| !long.TryParse(
|
||||||
|
arguments[6],
|
||||||
|
System.Globalization.NumberStyles.None,
|
||||||
|
System.Globalization.CultureInfo.InvariantCulture,
|
||||||
|
out long size))
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
string dataDirectory = Path.GetFullPath(arguments[0]);
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var manager = new LauncherSelfUpdateManager(Paths(dataDirectory), http);
|
||||||
|
_ = await manager.StageAsync(
|
||||||
|
LauncherVersion.Parse(arguments[2]),
|
||||||
|
arguments[3],
|
||||||
|
new ReleaseArtifact(new Uri(arguments[4]), arguments[5], size),
|
||||||
|
Path.GetFullPath(arguments[1]),
|
||||||
|
progress: null,
|
||||||
|
CancellationToken.None);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async Task<int> BootstrapProbeAsync(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 4)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var http = new HttpClient();
|
||||||
|
var manager = new LauncherSelfUpdateManager(Paths(arguments[0]), http);
|
||||||
|
SelfUpdateStartupResult result = await LauncherSelfUpdateBootstrap.HandleAsync(
|
||||||
|
["ordinary"],
|
||||||
|
manager,
|
||||||
|
Path.GetFullPath(arguments[1]),
|
||||||
|
Path.GetFullPath(arguments[2]));
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.GetFullPath(arguments[3]),
|
||||||
|
result.ShouldExit ? "exit" : string.Join("\n", result.RemainingArguments));
|
||||||
|
return result.ShouldExit ? 3 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int CanonicalProbe(string[] arguments)
|
||||||
|
{
|
||||||
|
if (arguments.Length != 1)
|
||||||
|
{
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
File.WriteAllText(
|
||||||
|
Path.GetFullPath(arguments[0]),
|
||||||
|
Environment.ProcessId.ToString(System.Globalization.CultureInfo.InvariantCulture)
|
||||||
|
+ "|"
|
||||||
|
+ Path.GetFullPath(
|
||||||
|
Environment.ProcessPath
|
||||||
|
?? throw new InvalidOperationException("Process path is unavailable.")));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
|
static async Task<int> HoldUpdateLeaseAsync(string[] arguments)
|
||||||
{
|
{
|
||||||
if (arguments.Length != 4
|
if (arguments.Length != 4
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,42 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
|
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DisposeKeepsUpdateLeaseUntilLiveChildIsObservedTerminal()
|
||||||
|
{
|
||||||
|
var supervisors = new BlockingStopSupervisorFactory();
|
||||||
|
var barrier = new UpdateSessionBarrier(_paths.DataDirectory);
|
||||||
|
LauncherOrchestrator orchestrator = CreateOrchestrator(
|
||||||
|
supervisorFactory: supervisors,
|
||||||
|
updateSessionBarrier: barrier);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_ = await orchestrator.LaunchAsync(
|
||||||
|
"Local ACE",
|
||||||
|
"testaccount",
|
||||||
|
"+Acdream",
|
||||||
|
LaunchMode.Headless);
|
||||||
|
BlockingStopSupervisor supervisor = Assert.Single(supervisors.Created);
|
||||||
|
|
||||||
|
Task disposal = Task.Run(orchestrator.Dispose);
|
||||||
|
Assert.True(supervisor.StopEntered.Wait(TimeSpan.FromSeconds(5)));
|
||||||
|
|
||||||
|
Assert.False(disposal.IsCompleted);
|
||||||
|
Assert.Throws<LauncherUpdateException>(barrier.AcquireExclusive);
|
||||||
|
|
||||||
|
supervisor.AllowTerminal.Set();
|
||||||
|
await disposal.WaitAsync(TimeSpan.FromSeconds(5));
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease update = barrier.AcquireExclusive();
|
||||||
|
Assert.Equal(LauncherSessionState.Exited, supervisor.State);
|
||||||
|
Assert.True(supervisor.Disposed);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
supervisors.AllowEveryStop();
|
||||||
|
orchestrator.Dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
public void SnapshotProjectsTheFullHierarchyWithoutTheCredential()
|
||||||
{
|
{
|
||||||
|
|
@ -756,6 +792,59 @@ public sealed class LauncherOrchestratorTests : IDisposable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingStopSupervisorFactory : ILauncherProcessSupervisorFactory
|
||||||
|
{
|
||||||
|
public List<BlockingStopSupervisor> Created { get; } = [];
|
||||||
|
|
||||||
|
public ILauncherProcessSupervisor Create()
|
||||||
|
{
|
||||||
|
var supervisor = new BlockingStopSupervisor();
|
||||||
|
Created.Add(supervisor);
|
||||||
|
return supervisor;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void AllowEveryStop()
|
||||||
|
{
|
||||||
|
foreach (BlockingStopSupervisor supervisor in Created)
|
||||||
|
{
|
||||||
|
supervisor.AllowTerminal.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class BlockingStopSupervisor : ILauncherProcessSupervisor
|
||||||
|
{
|
||||||
|
public ManualResetEventSlim StopEntered { get; } = new(false);
|
||||||
|
|
||||||
|
public ManualResetEventSlim AllowTerminal { get; } = new(false);
|
||||||
|
|
||||||
|
public LauncherSessionState State { get; private set; } =
|
||||||
|
LauncherSessionState.Starting;
|
||||||
|
|
||||||
|
public int? ExitCode { get; private set; }
|
||||||
|
|
||||||
|
public bool Disposed { get; private set; }
|
||||||
|
|
||||||
|
public event EventHandler<LauncherSessionState>? StateChanged;
|
||||||
|
|
||||||
|
public void Start(LauncherProcessSpec spec, string? password)
|
||||||
|
{
|
||||||
|
State = LauncherSessionState.Running;
|
||||||
|
StateChanged?.Invoke(this, State);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Stop(TimeSpan timeout)
|
||||||
|
{
|
||||||
|
StopEntered.Set();
|
||||||
|
AllowTerminal.Wait();
|
||||||
|
State = LauncherSessionState.Exited;
|
||||||
|
ExitCode = 0;
|
||||||
|
StateChanged?.Invoke(this, State);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
|
private sealed class QueueStatusSourceFactory : IStatusEventSourceFactory
|
||||||
{
|
{
|
||||||
public List<QueueStatusSource> Created { get; } = [];
|
public List<QueueStatusSource> Created { get; } = [];
|
||||||
|
|
|
||||||
|
|
@ -177,6 +177,61 @@ public sealed class ClientVersionStoreTests : IDisposable
|
||||||
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
|
Path.Combine(resolution.Directory!, "AcDream.App" + ExecutableSuffix)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task LinuxTreatsNonCanonicalInstallJsonCasingAsUnrecordedContent()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var store = new ClientVersionStore(_paths);
|
||||||
|
ClientVersionResolution installed = await PromoteAsync(store, "1.0.0", "linux-case");
|
||||||
|
await File.WriteAllTextAsync(
|
||||||
|
Path.Combine(installed.Directory!, "INSTALL.JSON"),
|
||||||
|
"must-not-be-hidden");
|
||||||
|
|
||||||
|
ClientVersionResolution resolution = await new ClientVersionStore(_paths)
|
||||||
|
.LoadAndRecoverAsync(_rid);
|
||||||
|
|
||||||
|
Assert.Equal(ClientVersionState.Invalid, resolution.State);
|
||||||
|
Assert.Contains("unrecorded", resolution.Status, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExclusiveStartupReclaimsOnlyCanonicalGuidOwnedResidue()
|
||||||
|
{
|
||||||
|
var store = new ClientVersionStore(_paths);
|
||||||
|
Directory.CreateDirectory(store.AppDirectory);
|
||||||
|
string id = Guid.NewGuid().ToString("N");
|
||||||
|
string nearId = id[..31] + "g";
|
||||||
|
string exactStaging = Path.Combine(store.AppDirectory, ".client-staging-" + id);
|
||||||
|
string exactCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + id);
|
||||||
|
string exactDownload = Path.Combine(
|
||||||
|
store.AppDirectory,
|
||||||
|
".client-download-" + id + ".zip");
|
||||||
|
string nearStaging = exactStaging + "-user";
|
||||||
|
string nearCorrupt = Path.Combine(store.AppDirectory, ".client-corrupt-" + nearId);
|
||||||
|
string nearDownload = Path.Combine(
|
||||||
|
store.AppDirectory,
|
||||||
|
".client-download-" + id + ".zip.user");
|
||||||
|
Directory.CreateDirectory(exactStaging);
|
||||||
|
Directory.CreateDirectory(exactCorrupt);
|
||||||
|
Directory.CreateDirectory(nearStaging);
|
||||||
|
Directory.CreateDirectory(nearCorrupt);
|
||||||
|
await File.WriteAllTextAsync(exactDownload, "owned");
|
||||||
|
await File.WriteAllTextAsync(nearDownload, "preserve");
|
||||||
|
|
||||||
|
_ = await store.LoadAndRecoverAsync(_rid);
|
||||||
|
|
||||||
|
Assert.False(Directory.Exists(exactStaging));
|
||||||
|
Assert.False(Directory.Exists(exactCorrupt));
|
||||||
|
Assert.False(File.Exists(exactDownload));
|
||||||
|
Assert.True(Directory.Exists(nearStaging));
|
||||||
|
Assert.True(Directory.Exists(nearCorrupt));
|
||||||
|
Assert.True(File.Exists(nearDownload));
|
||||||
|
}
|
||||||
|
|
||||||
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
|
private string ExecutableSuffix => _rid.StartsWith("win-", StringComparison.Ordinal)
|
||||||
? ".exe"
|
? ".exe"
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,9 @@
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
using AcDream.Launcher.Core.Updates;
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
namespace AcDream.Launcher.Core.Tests.Updates;
|
namespace AcDream.Launcher.Core.Tests.Updates;
|
||||||
|
|
||||||
public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions PlanOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
|
||||||
WriteIndented = true,
|
|
||||||
Converters =
|
|
||||||
{
|
|
||||||
new JsonStringEnumConverter<SelfUpdatePlanState>(
|
|
||||||
JsonNamingPolicy.CamelCase,
|
|
||||||
allowIntegerValues: false),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly string _root = Path.Combine(
|
private readonly string _root = Path.Combine(
|
||||||
Path.GetTempPath(),
|
Path.GetTempPath(),
|
||||||
"acdream-self-update-tests",
|
"acdream-self-update-tests",
|
||||||
|
|
@ -47,6 +33,12 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
await File.WriteAllTextAsync(unrelated, "preserve");
|
await File.WriteAllTextAsync(unrelated, "preserve");
|
||||||
|
|
||||||
Assert.Null(await harness.Manager.LoadPendingAsync());
|
Assert.Null(await harness.Manager.LoadPendingAsync());
|
||||||
|
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||||
|
harness.Manager.Barrier.AcquireExclusive();
|
||||||
|
Assert.True(harness.Manager.CleanupOwnedResidueUnderLease(
|
||||||
|
pending: null,
|
||||||
|
harness.Target,
|
||||||
|
lease));
|
||||||
|
|
||||||
Assert.False(Directory.Exists(orphan));
|
Assert.False(Directory.Exists(orphan));
|
||||||
Assert.False(File.Exists(temporary));
|
Assert.False(File.Exists(temporary));
|
||||||
|
|
@ -121,49 +113,91 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CrashDuringApplyingReplaysReverseJournalToStagedState()
|
public async Task PriorOwnershipRemovesObsoleteFilesAndRollbackRestoresThem()
|
||||||
|
{
|
||||||
|
using var harness = new Harness(_root);
|
||||||
|
byte[] firstArchive = UpdateTestData.CreateZip(
|
||||||
|
[
|
||||||
|
(harness.LauncherName, "launcher-v2"u8.ToArray(), 0x81ED),
|
||||||
|
("support.dat", "support-v2"u8.ToArray(), 0x81A4),
|
||||||
|
("obsolete.dll", "obsolete-v2"u8.ToArray(), 0x81A4),
|
||||||
|
]);
|
||||||
|
_ = await harness.StageAsync("2.0.0", firstArchive);
|
||||||
|
SelfUpdatePlan first = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
|
await harness.Manager.ConfirmAsync(
|
||||||
|
first.TransactionId,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath);
|
||||||
|
await harness.Manager.CompleteConfirmedAsync(first.TransactionId, harness.Target);
|
||||||
|
string obsoletePath = Path.Combine(harness.Target, "obsolete.dll");
|
||||||
|
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
|
||||||
|
|
||||||
|
byte[] secondArchive = UpdateTestData.CreateZip(
|
||||||
|
[
|
||||||
|
(harness.LauncherName, "launcher-v3"u8.ToArray(), 0x81ED),
|
||||||
|
("support.dat", "support-v3"u8.ToArray(), 0x81A4),
|
||||||
|
]);
|
||||||
|
_ = await harness.StageAsync("3.0.0", secondArchive);
|
||||||
|
SelfUpdatePlan applied = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(obsoletePath));
|
||||||
|
Assert.Contains(
|
||||||
|
applied.Apply!,
|
||||||
|
entry => entry.Path == "obsolete.dll"
|
||||||
|
&& entry.Operation == SelfUpdateApplyOperation.Remove
|
||||||
|
&& entry.HadOriginal);
|
||||||
|
|
||||||
|
SelfUpdatePlan rolledBack = await harness.Manager
|
||||||
|
.RollbackAwaitingConfirmationAsync(harness.Target);
|
||||||
|
|
||||||
|
Assert.Equal(SelfUpdatePlanState.Staged, rolledBack.State);
|
||||||
|
Assert.Equal("launcher-v2", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||||
|
Assert.Equal("support-v2", await File.ReadAllTextAsync(harness.SupportPath));
|
||||||
|
Assert.Equal("obsolete-v2", await File.ReadAllTextAsync(obsoletePath));
|
||||||
|
|
||||||
|
SelfUpdatePlan retried = await harness.Manager.ApplyPendingAsync(harness.Target);
|
||||||
|
await harness.Manager.ConfirmAsync(
|
||||||
|
retried.TransactionId,
|
||||||
|
harness.Target,
|
||||||
|
harness.LauncherPath);
|
||||||
|
await harness.Manager.CompleteConfirmedAsync(retried.TransactionId, harness.Target);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(obsoletePath));
|
||||||
|
string ownership = await File.ReadAllTextAsync(Path.Combine(
|
||||||
|
harness.Target,
|
||||||
|
LauncherSelfUpdateManager.InstallRecordFileName));
|
||||||
|
Assert.DoesNotContain("obsolete.dll", ownership, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ApplyFailpointAfterCanonicalAtomicReplaceRollsBackToStagedState()
|
||||||
{
|
{
|
||||||
using var harness = new Harness(_root);
|
using var harness = new Harness(_root);
|
||||||
_ = await harness.StageAsync();
|
_ = await harness.StageAsync();
|
||||||
SelfUpdatePlan staged = Assert.IsType<SelfUpdatePlan>(
|
LauncherSelfUpdateManager faulting = harness.CreateManagerWithObserver(observation =>
|
||||||
await harness.Manager.LoadPendingAsync());
|
|
||||||
SelfUpdateApplyEntry[] apply = staged.Files
|
|
||||||
.Select(file => new SelfUpdateApplyEntry(
|
|
||||||
file.Path,
|
|
||||||
File.Exists(Path.Combine(
|
|
||||||
harness.Target,
|
|
||||||
file.Path.Replace('/', Path.DirectorySeparatorChar)))))
|
|
||||||
.ToArray();
|
|
||||||
SelfUpdatePlan applying = staged with
|
|
||||||
{
|
{
|
||||||
State = SelfUpdatePlanState.Applying,
|
if (observation.Boundary == SelfUpdateApplyBoundary.AfterTargetMutation
|
||||||
Apply = apply,
|
&& string.Equals(
|
||||||
};
|
observation.Path,
|
||||||
await File.WriteAllTextAsync(
|
harness.LauncherName,
|
||||||
harness.Manager.PendingPlanPath,
|
StringComparison.Ordinal))
|
||||||
JsonSerializer.Serialize(applying, PlanOptions));
|
{
|
||||||
|
Assert.True(File.Exists(harness.LauncherPath));
|
||||||
|
throw new InvalidOperationException("failpoint");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
SelfUpdateApplyEntry first = apply[0];
|
InvalidOperationException failure = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
string payload = Path.Combine(
|
() => faulting.ApplyPendingAsync(harness.Target));
|
||||||
harness.Manager.GetPayloadDirectory(staged.TransactionId),
|
SelfUpdatePlan recovered = Assert.IsType<SelfUpdatePlan>(
|
||||||
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
await harness.Manager.LoadPendingAsync());
|
||||||
string target = Path.Combine(
|
|
||||||
harness.Target,
|
|
||||||
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
|
||||||
string backup = Path.Combine(
|
|
||||||
harness.Manager.GetBackupDirectory(staged.TransactionId),
|
|
||||||
first.Path.Replace('/', Path.DirectorySeparatorChar));
|
|
||||||
Directory.CreateDirectory(Path.GetDirectoryName(backup)!);
|
|
||||||
File.Move(target, backup);
|
|
||||||
File.Move(payload, target);
|
|
||||||
|
|
||||||
SelfUpdatePlan recovered = await harness.Manager.RecoverApplyingAsync(harness.Target);
|
|
||||||
|
|
||||||
|
Assert.Equal("failpoint", failure.Message);
|
||||||
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
|
Assert.Equal(SelfUpdatePlanState.Staged, recovered.State);
|
||||||
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
Assert.Equal("old-launcher", await File.ReadAllTextAsync(harness.LauncherPath));
|
||||||
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
|
Assert.Equal("old-support", await File.ReadAllTextAsync(harness.SupportPath));
|
||||||
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
|
Assert.Equal("new-launcher", await File.ReadAllTextAsync(Path.Combine(
|
||||||
harness.Manager.GetPayloadDirectory(staged.TransactionId),
|
harness.Manager.GetPayloadDirectory(recovered.TransactionId),
|
||||||
harness.LauncherName)));
|
harness.LauncherName)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,8 +253,8 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
|
|
||||||
Assert.False(confirmation.ShouldExit);
|
Assert.False(confirmation.ShouldExit);
|
||||||
Assert.Empty(confirmation.RemainingArguments);
|
Assert.Empty(confirmation.RemainingArguments);
|
||||||
Assert.True(harness.Manager.IsConfirmed(applied.TransactionId));
|
Assert.False(File.Exists(harness.Manager.PendingPlanPath));
|
||||||
await harness.Manager.CompleteConfirmedAsync(applied.TransactionId, harness.Target);
|
Assert.False(harness.Manager.IsConfirmed(applied.TransactionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class Harness : IDisposable
|
private sealed class Harness : IDisposable
|
||||||
|
|
@ -229,9 +263,11 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
private readonly HttpClient _http = new();
|
private readonly HttpClient _http = new();
|
||||||
private readonly byte[] _archive;
|
private readonly byte[] _archive;
|
||||||
private readonly ReleaseArtifact _artifact;
|
private readonly ReleaseArtifact _artifact;
|
||||||
|
private readonly string _root;
|
||||||
|
|
||||||
public Harness(string root)
|
public Harness(string root)
|
||||||
{
|
{
|
||||||
|
_root = root;
|
||||||
Target = Path.Combine(root, "published launcher");
|
Target = Path.Combine(root, "published launcher");
|
||||||
Directory.CreateDirectory(Target);
|
Directory.CreateDirectory(Target);
|
||||||
Rid = LauncherRuntimeIdentity.DetectRid();
|
Rid = LauncherRuntimeIdentity.DetectRid();
|
||||||
|
|
@ -270,6 +306,25 @@ public sealed class LauncherSelfUpdateManagerTests : IDisposable
|
||||||
progress: null,
|
progress: null,
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
|
public Task<SelfUpdateStageResult> StageAsync(string version, byte[] archive)
|
||||||
|
{
|
||||||
|
_server.Add("launcher.zip", archive);
|
||||||
|
return Manager.StageAsync(
|
||||||
|
LauncherVersion.Parse(version),
|
||||||
|
Rid,
|
||||||
|
new ReleaseArtifact(
|
||||||
|
_server.UriFor("launcher.zip"),
|
||||||
|
UpdateTestData.Sha256(archive),
|
||||||
|
archive.LongLength),
|
||||||
|
Target,
|
||||||
|
progress: null,
|
||||||
|
CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
public LauncherSelfUpdateManager CreateManagerWithObserver(
|
||||||
|
Action<SelfUpdateApplyObservation> observer) =>
|
||||||
|
new(UpdateTestData.Paths(_root), _http, null, observer);
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
_http.Dispose();
|
_http.Dispose();
|
||||||
|
|
|
||||||
|
|
@ -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");
|
byte[] launcher = UpdateTestData.LauncherZip(_rid, "release-2");
|
||||||
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
|
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||||
|
server.UriFor("manifest.json"));
|
||||||
var versions = new ClientVersionStore(_paths);
|
var versions = new ClientVersionStore(_paths);
|
||||||
var updater = new LauncherUpdater(
|
var updater = new LauncherUpdater(
|
||||||
source,
|
source,
|
||||||
|
|
@ -74,7 +75,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
||||||
byte[] launcher = UpdateTestData.LauncherZip(_rid);
|
byte[] launcher = UpdateTestData.LauncherZip(_rid);
|
||||||
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
|
ConfigureRelease(server, "3.0.0", "2.0.0", _rid, client, launcher);
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||||
|
server.UriFor("manifest.json"));
|
||||||
var versions = new ClientVersionStore(_paths);
|
var versions = new ClientVersionStore(_paths);
|
||||||
var updater = new LauncherUpdater(
|
var updater = new LauncherUpdater(
|
||||||
source,
|
source,
|
||||||
|
|
@ -108,7 +110,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
||||||
byte[] launcher = UpdateTestData.LauncherZip(_rid);
|
byte[] launcher = UpdateTestData.LauncherZip(_rid);
|
||||||
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
|
ConfigureRelease(server, "2.0.0", "1.0.0", _rid, client, launcher);
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||||
|
server.UriFor("manifest.json"));
|
||||||
var versions = new ClientVersionStore(_paths);
|
var versions = new ClientVersionStore(_paths);
|
||||||
bool running = true;
|
bool running = true;
|
||||||
var updater = new LauncherUpdater(
|
var updater = new LauncherUpdater(
|
||||||
|
|
@ -156,7 +159,8 @@ public sealed class LauncherUpdaterIntegrationTests : IDisposable
|
||||||
launcher),
|
launcher),
|
||||||
contentType: "application/json");
|
contentType: "application/json");
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||||
|
server.UriFor("manifest.json"));
|
||||||
var versions = new ClientVersionStore(_paths);
|
var versions = new ClientVersionStore(_paths);
|
||||||
var updater = new LauncherUpdater(
|
var updater = new LauncherUpdater(
|
||||||
source,
|
source,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Net;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using AcDream.Launcher.Core.Updates;
|
using AcDream.Launcher.Core.Updates;
|
||||||
|
|
||||||
|
|
@ -63,7 +64,8 @@ public sealed class ReleaseManifestClientTests
|
||||||
launcher),
|
launcher),
|
||||||
contentType: "application/json");
|
contentType: "application/json");
|
||||||
using var http = new HttpClient();
|
using var http = new HttpClient();
|
||||||
using var source = new ReleaseManifestClient(http, server.UriFor("manifest.json"));
|
using var source = ReleaseManifestClient.CreateLoopbackFixture(
|
||||||
|
server.UriFor("manifest.json"));
|
||||||
|
|
||||||
ReleaseManifest manifest = await source.FetchAsync();
|
ReleaseManifest manifest = await source.FetchAsync();
|
||||||
|
|
||||||
|
|
@ -96,16 +98,132 @@ public sealed class ReleaseManifestClientTests
|
||||||
ValidJson().Replace("\"size\":12", "\"size\":0"),
|
ValidJson().Replace("\"size\":12", "\"size\":0"),
|
||||||
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
|
ValidJson().Replace("\"size\":12", "\"size\":12,\"extra\":true"),
|
||||||
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
|
ValidJson().Replace("\"version\":\"2.0.0\"", "\"version\":\"2.0.0\",\"version\":\"2.0.1\""),
|
||||||
ValidJson().Replace("http://127.0.0.1", "http://example.test"),
|
ValidJson().Replace("https://example.test/client", "http://example.test/client"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("clients", "client")]
|
||||||
|
[InlineData("launchers", "launcher")]
|
||||||
|
public void ProductionManifestRejectsLoopbackHttpArtifacts(
|
||||||
|
string section,
|
||||||
|
string artifact)
|
||||||
|
{
|
||||||
|
string json = ValidJson().Replace(
|
||||||
|
$"https://example.test/{artifact}",
|
||||||
|
$"http://127.0.0.1/{artifact}",
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
LauncherUpdateException error = Assert.Throws<LauncherUpdateException>(() =>
|
||||||
|
ReleaseManifestClient.Parse(Encoding.UTF8.GetBytes(json)));
|
||||||
|
|
||||||
|
Assert.Contains(section, error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ProductionRedirectToLoopbackIsRejectedBeforePlaintextRequest()
|
||||||
|
{
|
||||||
|
var handler = new SequenceHandler((request, _) => Redirect(
|
||||||
|
HttpStatusCode.Found,
|
||||||
|
new Uri("http://127.0.0.1/manifest.json")));
|
||||||
|
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||||
|
ReleaseManifestClient.ProductionManifestUri,
|
||||||
|
allowLoopbackHttp: false,
|
||||||
|
handler);
|
||||||
|
|
||||||
|
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||||
|
() => source.FetchAsync());
|
||||||
|
|
||||||
|
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Equal([ReleaseManifestClient.ProductionManifestUri], handler.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HttpsRedirectDowngradeIsRejectedBeforeIntermediateHop()
|
||||||
|
{
|
||||||
|
var start = new Uri("https://example.test/start");
|
||||||
|
var handler = new SequenceHandler((request, _) => Redirect(
|
||||||
|
HttpStatusCode.TemporaryRedirect,
|
||||||
|
new Uri("http://example.test/plaintext-hop")));
|
||||||
|
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||||
|
start,
|
||||||
|
allowLoopbackHttp: false,
|
||||||
|
handler);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<LauncherUpdateException>(() => source.FetchAsync());
|
||||||
|
|
||||||
|
Assert.Equal([start], handler.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RedirectLoopIsRejectedWithoutRepeatingARequest()
|
||||||
|
{
|
||||||
|
var first = new Uri("https://example.test/first");
|
||||||
|
var second = new Uri("https://example.test/second");
|
||||||
|
var handler = new SequenceHandler((request, _) => Redirect(
|
||||||
|
HttpStatusCode.PermanentRedirect,
|
||||||
|
request.RequestUri == first ? second : first));
|
||||||
|
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||||
|
first,
|
||||||
|
allowLoopbackHttp: false,
|
||||||
|
handler);
|
||||||
|
|
||||||
|
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||||
|
() => source.FetchAsync());
|
||||||
|
|
||||||
|
Assert.Contains("loop", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Equal([first, second], handler.Requests);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RedirectLimitRejectsBeforeRequestingTheSixthHop()
|
||||||
|
{
|
||||||
|
var start = new Uri("https://example.test/hop-0");
|
||||||
|
var handler = new SequenceHandler((_, index) => Redirect(
|
||||||
|
HttpStatusCode.Found,
|
||||||
|
new Uri($"https://example.test/hop-{index + 1}")));
|
||||||
|
using var source = ReleaseManifestClient.CreateForTransportTest(
|
||||||
|
start,
|
||||||
|
allowLoopbackHttp: false,
|
||||||
|
handler);
|
||||||
|
|
||||||
|
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(
|
||||||
|
() => source.FetchAsync());
|
||||||
|
|
||||||
|
Assert.Contains("5 redirects", error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Equal(6, handler.Requests.Count);
|
||||||
|
Assert.Equal(new Uri("https://example.test/hop-5"), handler.Requests[^1]);
|
||||||
|
}
|
||||||
|
|
||||||
private static string ValidJson() =>
|
private static string ValidJson() =>
|
||||||
"{\"schemaVersion\":1,\"version\":\"2.0.0\","
|
$$$$"""
|
||||||
+ "\"minimumLauncherVersion\":\"1.0.0\","
|
{"schemaVersion":1,"version":"2.0.0","minimumLauncherVersion":"1.0.0","clients":{"win-x64":{"url":"https://example.test/client","sha256":"{{{{new string('a', 64)}}}}","size":12}},"launchers":{"win-x64":{"url":"https://example.test/launcher","sha256":"{{{{new string('b', 64)}}}}","size":12}}}
|
||||||
+ "\"clients\":{\"win-x64\":{\"url\":\"http://127.0.0.1/client\","
|
""";
|
||||||
+ $"\"sha256\":\"{new string('a', 64)}\",\"size\":12}},"
|
|
||||||
+ "\"launchers\":{\"win-x64\":{\"url\":\"https://example.test/launcher\","
|
private static HttpResponseMessage Redirect(HttpStatusCode status, Uri location)
|
||||||
+ $"\"sha256\":\"{new string('b', 64)}\",\"size\":12}}}}";
|
{
|
||||||
|
var response = new HttpResponseMessage(status);
|
||||||
|
response.Headers.Location = location;
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class SequenceHandler(
|
||||||
|
Func<HttpRequestMessage, int, HttpResponseMessage> respond)
|
||||||
|
: HttpMessageHandler
|
||||||
|
{
|
||||||
|
public List<Uri> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Uri uri = request.RequestUri
|
||||||
|
?? throw new InvalidOperationException("Test request has no URI.");
|
||||||
|
int index = Requests.Count;
|
||||||
|
Requests.Add(uri);
|
||||||
|
return Task.FromResult(respond(request, index));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class VerifiedArtifactDownloaderTests : IDisposable
|
public sealed class VerifiedArtifactDownloaderTests : IDisposable
|
||||||
|
|
@ -211,9 +329,45 @@ public sealed class VerifiedArtifactDownloaderTests : IDisposable
|
||||||
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
|
Assert.Equal("preserve", await File.ReadAllTextAsync(destination));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task HttpsArtifactRedirectDowngradeIsRejectedBeforePlaintextHop()
|
||||||
|
{
|
||||||
|
var handler = new RedirectHandler();
|
||||||
|
using var http = new HttpClient(handler);
|
||||||
|
var downloader = new VerifiedArtifactDownloader(http);
|
||||||
|
string destination = Path.Combine(_root, "redirect.zip");
|
||||||
|
|
||||||
|
LauncherUpdateException error = await Assert.ThrowsAsync<LauncherUpdateException>(() =>
|
||||||
|
downloader.DownloadAsync(
|
||||||
|
new ReleaseArtifact(
|
||||||
|
new Uri("https://example.test/artifact"),
|
||||||
|
new string('a', 64),
|
||||||
|
12),
|
||||||
|
destination));
|
||||||
|
|
||||||
|
Assert.Contains("HTTPS", error.Message, StringComparison.Ordinal);
|
||||||
|
Assert.Equal([new Uri("https://example.test/artifact")], handler.Requests);
|
||||||
|
Assert.False(File.Exists(destination));
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
|
private sealed class ImmediateProgress(Action<ArtifactDownloadProgress> callback)
|
||||||
: IProgress<ArtifactDownloadProgress>
|
: IProgress<ArtifactDownloadProgress>
|
||||||
{
|
{
|
||||||
public void Report(ArtifactDownloadProgress value) => callback(value);
|
public void Report(ArtifactDownloadProgress value) => callback(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class RedirectHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public List<Uri> Requests { get; } = [];
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
Requests.Add(request.RequestUri!);
|
||||||
|
var response = new HttpResponseMessage(HttpStatusCode.Found);
|
||||||
|
response.Headers.Location = new Uri("http://example.test/plaintext");
|
||||||
|
return Task.FromResult(response);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,12 @@ public sealed class SafeZipExtractorTests : IDisposable
|
||||||
[InlineData("a/./b")]
|
[InlineData("a/./b")]
|
||||||
[InlineData("CON")]
|
[InlineData("CON")]
|
||||||
[InlineData("aux.txt")]
|
[InlineData("aux.txt")]
|
||||||
|
[InlineData("CLOCK$/value")]
|
||||||
|
[InlineData("CONIN$.txt")]
|
||||||
|
[InlineData("CONOUT$/value")]
|
||||||
|
[InlineData("COM¹.dll")]
|
||||||
|
[InlineData("com²/value")]
|
||||||
|
[InlineData("LPT³.log")]
|
||||||
[InlineData("trailing.")]
|
[InlineData("trailing.")]
|
||||||
[InlineData("trailing ")]
|
[InlineData("trailing ")]
|
||||||
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
|
public async Task RejectsTraversalRootedAdsAndPortableUnsafeNames(string entry)
|
||||||
|
|
@ -66,6 +72,15 @@ public sealed class SafeZipExtractorTests : IDisposable
|
||||||
Assert.False(File.Exists(Path.Combine(_root, "escape")));
|
Assert.False(File.Exists(Path.Combine(_root, "escape")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("CONIN$.txt")]
|
||||||
|
[InlineData("CONOUT$/child")]
|
||||||
|
[InlineData("COM¹.dll")]
|
||||||
|
[InlineData("LPT³/child")]
|
||||||
|
[InlineData("CLOCK$")]
|
||||||
|
public void VersionMetadataUsesTheSameCompletePortableDeviceRules(string path) =>
|
||||||
|
Assert.False(ClientVersionStore.IsNormalizedRelative(path));
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
|
public async Task RejectsDuplicateCaseAndFileDirectoryCollisionsBeforeExtraction()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -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