fix(launcher): harden updater crash recovery
This commit is contained in:
parent
2d2a5b5046
commit
1955ca8ab5
27 changed files with 2714 additions and 544 deletions
|
|
@ -44,6 +44,7 @@ public sealed class LauncherProcessSupervisorFactory(
|
|||
/// </summary>
|
||||
public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
||||
{
|
||||
private static readonly TimeSpan DisposeStopTimeout = TimeSpan.FromSeconds(5);
|
||||
private readonly ILauncherChildProcessFactory _factory;
|
||||
private readonly object _gate = new();
|
||||
private readonly Queue<LauncherSessionState> _pendingStateChanges = [];
|
||||
|
|
@ -51,6 +52,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
|||
private LauncherSessionState _state = LauncherSessionState.Starting;
|
||||
private int? _exitCode;
|
||||
private bool _publishingStateChanges;
|
||||
private bool _disposed;
|
||||
|
||||
public LauncherProcessSupervisor(ILauncherChildProcessFactory? factory = null)
|
||||
{
|
||||
|
|
@ -100,6 +102,7 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
|||
ILauncherChildProcess process;
|
||||
lock (_gate)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (_process is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
|
|
@ -201,6 +204,11 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
|||
if (!process.WaitForExit(timeout) && !process.HasExited)
|
||||
{
|
||||
process.Kill();
|
||||
if (!process.WaitForExit(Timeout.InfiniteTimeSpan) && !process.HasExited)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The launcher child could not be observed terminal after it was killed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +315,23 @@ public sealed class LauncherProcessSupervisor : ILauncherProcessSupervisor
|
|||
|
||||
public void Dispose()
|
||||
{
|
||||
ILauncherChildProcess? process;
|
||||
lock (_gate)
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
process = _process;
|
||||
}
|
||||
|
||||
if (process is { HasExited: false })
|
||||
{
|
||||
Stop(DisposeStopTimeout);
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_process is not null)
|
||||
|
|
|
|||
|
|
@ -149,6 +149,13 @@ public sealed class LauncherExecutableSet
|
|||
});
|
||||
}
|
||||
|
||||
public static LauncherExecutableSet Unavailable(string reason)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(reason);
|
||||
return new LauncherExecutableSet(
|
||||
() => throw new LauncherUpdateException(reason));
|
||||
}
|
||||
|
||||
private ExecutablePaths RequireAvailable(LaunchMode mode)
|
||||
{
|
||||
LauncherCapability capability = GetAvailability(mode);
|
||||
|
|
|
|||
|
|
@ -627,6 +627,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
}
|
||||
|
||||
request.Cancellation.Dispose();
|
||||
request.Activity.StartCompleted.Set();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1201,11 +1202,16 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
private static void DisposeActivity(ManagedActivity activity)
|
||||
{
|
||||
activity.StartCancellation?.Cancel();
|
||||
activity.StartCompleted.Wait();
|
||||
activity.StartCancellation?.Dispose();
|
||||
activity.StartCancellation = null;
|
||||
|
||||
if (activity.Supervisor is not null)
|
||||
{
|
||||
// Disposal is a process-lifetime transaction: the shared update
|
||||
// lease remains held until Stop has observed the real child
|
||||
// terminal (including the post-kill wait).
|
||||
activity.Supervisor.Stop(TimeSpan.FromSeconds(5));
|
||||
if (activity.SupervisorStateHandler is not null)
|
||||
{
|
||||
activity.Supervisor.StateChanged -= activity.SupervisorStateHandler;
|
||||
|
|
@ -1216,6 +1222,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
}
|
||||
|
||||
ReleaseUpdateSessionLease(activity);
|
||||
activity.StartCompleted.Dispose();
|
||||
}
|
||||
|
||||
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
|
||||
|
|
@ -1285,6 +1292,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
|
||||
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
|
||||
|
||||
public ManualResetEventSlim StartCompleted { get; } = new(false);
|
||||
|
||||
public object StatusReadGate { get; } = new();
|
||||
|
||||
public bool IsActive => State is not (
|
||||
|
|
|
|||
|
|
@ -503,7 +503,9 @@ public sealed class ClientVersionStore
|
|||
.Where(path => !string.Equals(
|
||||
path,
|
||||
"install.json",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] recordedFiles = record.Files
|
||||
|
|
@ -809,13 +811,43 @@ public sealed class ClientVersionStore
|
|||
".client-staging-*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
|
||||
if (Guid.TryParseExact(suffix, "N", out _))
|
||||
if (HasCanonicalGuidName(
|
||||
Path.GetFileName(path),
|
||||
".client-staging-",
|
||||
string.Empty))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in Directory.EnumerateDirectories(
|
||||
AppDirectory,
|
||||
".client-corrupt-*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (HasCanonicalGuidName(
|
||||
Path.GetFileName(path),
|
||||
".client-corrupt-",
|
||||
string.Empty))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in Directory.EnumerateFiles(
|
||||
AppDirectory,
|
||||
".client-download-*.zip",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
if (HasCanonicalGuidName(
|
||||
Path.GetFileName(path),
|
||||
".client-download-",
|
||||
".zip"))
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in Directory.EnumerateFiles(
|
||||
AppDirectory,
|
||||
".current*.tmp",
|
||||
|
|
@ -825,20 +857,43 @@ public sealed class ClientVersionStore
|
|||
string[] parts = fileName.Split('.');
|
||||
if (parts.Length >= 4
|
||||
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
|
||||
&& Guid.TryParseExact(parts[^2], "N", out _))
|
||||
&& Guid.TryParseExact(parts[^2], "N", out Guid parsed)
|
||||
&& string.Equals(
|
||||
parsed.ToString("N"),
|
||||
parts[^2],
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasCanonicalGuidName(
|
||||
string fileName,
|
||||
string prefix,
|
||||
string suffix)
|
||||
{
|
||||
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|
||||
|| !fileName.EndsWith(suffix, StringComparison.Ordinal)
|
||||
|| fileName.Length != prefix.Length + 32 + suffix.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string value = fileName.Substring(prefix.Length, 32);
|
||||
return Guid.TryParseExact(value, "N", out Guid parsed)
|
||||
&& string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private void RequireOwnedStagingPath(string path)
|
||||
{
|
||||
string parent = Path.GetDirectoryName(path) ?? string.Empty;
|
||||
string fileName = Path.GetFileName(path);
|
||||
if (!PathsEqual(parent, AppDirectory)
|
||||
|| !fileName.StartsWith(".client-staging-", StringComparison.Ordinal)
|
||||
|| !Guid.TryParseExact(fileName[".client-staging-".Length..], "N", out _))
|
||||
|| !HasCanonicalGuidName(
|
||||
fileName,
|
||||
".client-staging-",
|
||||
string.Empty))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The client extraction path is not an owned LA10 staging directory.");
|
||||
|
|
@ -901,7 +956,7 @@ public sealed class ClientVersionStore
|
|||
&& !part.Any(character =>
|
||||
char.IsControl(character)
|
||||
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||
&& !IsWindowsDeviceName(part));
|
||||
&& !PortablePathRules.IsWindowsDeviceName(part));
|
||||
}
|
||||
|
||||
internal static string ResolveContained(string root, string relative)
|
||||
|
|
@ -929,19 +984,6 @@ public sealed class ClientVersionStore
|
|||
return path;
|
||||
}
|
||||
|
||||
private static bool IsWindowsDeviceName(string segment)
|
||||
{
|
||||
string stem = segment.Split('.')[0];
|
||||
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
||||
|| (stem.Length == 4
|
||||
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
|
||||
&& stem[3] is >= '1' and <= '9');
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) =>
|
||||
string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||
|
|
|
|||
|
|
@ -8,16 +8,17 @@ public sealed record SelfUpdateStartupResult(
|
|||
string[] RemainingArguments);
|
||||
|
||||
/// <summary>
|
||||
/// Process-level rename dance for launcher self-update. Every child argument
|
||||
/// is passed through <see cref="ProcessStartInfo.ArgumentList"/> with
|
||||
/// <c>UseShellExecute=false</c>; no path or PID is ever interpolated into a
|
||||
/// shell command.
|
||||
/// Process-level self-update bootstrap. Every child argument is passed through
|
||||
/// <see cref="ProcessStartInfo.ArgumentList"/> with shell execution disabled.
|
||||
/// </summary>
|
||||
public static class LauncherSelfUpdateBootstrap
|
||||
{
|
||||
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
||||
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
||||
internal const string DeferredArgument = "--acdream-self-update-deferred-v1";
|
||||
internal const int DeferredLeaseExitCode = 73;
|
||||
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
||||
private static readonly TimeSpan CleanupTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
||||
string[] args,
|
||||
|
|
@ -32,10 +33,16 @@ public static class LauncherSelfUpdateBootstrap
|
|||
Path.GetFullPath(launcherBaseDirectory));
|
||||
string executable = Path.GetFullPath(currentExecutablePath);
|
||||
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], DeferredArgument, StringComparison.Ordinal))
|
||||
{
|
||||
return new SelfUpdateStartupResult(false, 0, args[1..]);
|
||||
}
|
||||
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
||||
{
|
||||
if (args.Length != 4
|
||||
if (args.Length < 4
|
||||
|| !int.TryParse(
|
||||
args[1],
|
||||
System.Globalization.NumberStyles.None,
|
||||
|
|
@ -51,6 +58,7 @@ public static class LauncherSelfUpdateBootstrap
|
|||
parentPid,
|
||||
args[2],
|
||||
args[3],
|
||||
args[4..],
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new SelfUpdateStartupResult(true, exitCode, []);
|
||||
|
|
@ -59,7 +67,7 @@ public static class LauncherSelfUpdateBootstrap
|
|||
if (args.Length > 0
|
||||
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
|
||||
{
|
||||
if (args.Length != 2)
|
||||
if (args.Length < 2)
|
||||
{
|
||||
return new SelfUpdateStartupResult(true, 64, []);
|
||||
}
|
||||
|
|
@ -70,82 +78,97 @@ public static class LauncherSelfUpdateBootstrap
|
|||
executable,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new SelfUpdateStartupResult(false, 0, []);
|
||||
}
|
||||
|
||||
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (plan is null)
|
||||
{
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update targets a different launcher directory.");
|
||||
}
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
if (!manager.IsConfirmed(plan.TransactionId))
|
||||
{
|
||||
await manager.ConfirmAsync(
|
||||
plan.TransactionId,
|
||||
baseDirectory,
|
||||
executable,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await manager.CompleteConfirmedAsync(
|
||||
plan.TransactionId,
|
||||
await FinishConfirmedCleanupAsync(
|
||||
manager,
|
||||
baseDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new SelfUpdateStartupResult(false, 0, args[2..]);
|
||||
}
|
||||
|
||||
if (!manager.Barrier.TryAcquireExclusive(
|
||||
out UpdateSessionBarrier.ExclusiveLease? startupLease))
|
||||
{
|
||||
// A running session or another launcher is staging. Reading the
|
||||
// plan is safe, but cleanup or starting a competing helper is not.
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||
baseDirectory,
|
||||
"acdream-launcher" + suffix);
|
||||
if (!PathsEqual(executable, expectedExecutable))
|
||||
using (UpdateSessionBarrier.ExclusiveLease lease = startupLease
|
||||
?? throw new InvalidOperationException("Exclusive startup lease is missing."))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update can start only from the published acdream-launcher executable.");
|
||||
}
|
||||
SelfUpdatePlan? plan = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_ = manager.CleanupOwnedResidueUnderLease(
|
||||
plan,
|
||||
baseDirectory,
|
||||
lease);
|
||||
if (plan is null)
|
||||
{
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
string helperPath = manager.GetHelperPath(plan.TransactionId);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(helperPath)!);
|
||||
VerifiedArtifactDownloader.TryDelete(helperPath);
|
||||
File.Copy(executable, helperPath, overwrite: false);
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
File.SetUnixFileMode(
|
||||
helperPath,
|
||||
UnixFileMode.UserRead
|
||||
| UnixFileMode.UserWrite
|
||||
| UnixFileMode.UserExecute);
|
||||
}
|
||||
if (!PathsEqual(plan.TargetDirectory, baseDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update targets a different launcher directory.");
|
||||
}
|
||||
|
||||
var startInfo = new ProcessStartInfo(helperPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = manager.GetTransactionDirectory(plan.TransactionId),
|
||||
};
|
||||
startInfo.ArgumentList.Add(HelperArgument);
|
||||
startInfo.ArgumentList.Add(
|
||||
Environment.ProcessId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
startInfo.ArgumentList.Add(baseDirectory);
|
||||
startInfo.ArgumentList.Add(plan.TransactionId);
|
||||
_ = Process.Start(startInfo)
|
||||
?? throw new LauncherUpdateException(
|
||||
"The launcher self-update helper could not be started.");
|
||||
return new SelfUpdateStartupResult(true, 0, []);
|
||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
if (!manager.IsConfirmed(plan.TransactionId))
|
||||
{
|
||||
await manager.ConfirmAsync(
|
||||
plan.TransactionId,
|
||||
baseDirectory,
|
||||
executable,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await manager.CompleteConfirmedAsync(
|
||||
plan.TransactionId,
|
||||
baseDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_ = manager.CleanupOwnedResidueUnderLease(
|
||||
pending: null,
|
||||
baseDirectory,
|
||||
lease);
|
||||
return new SelfUpdateStartupResult(false, 0, args);
|
||||
}
|
||||
|
||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||
baseDirectory,
|
||||
GetLauncherFileName(plan.Rid));
|
||||
if (!PathsEqual(executable, expectedExecutable))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update can start only from the published acdream-launcher executable.");
|
||||
}
|
||||
|
||||
string helperPath = manager.GetStagedLauncherPath(plan);
|
||||
var startInfo = new ProcessStartInfo(helperPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = manager.GetPayloadDirectory(plan.TransactionId),
|
||||
};
|
||||
startInfo.ArgumentList.Add(HelperArgument);
|
||||
startInfo.ArgumentList.Add(
|
||||
Environment.ProcessId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture));
|
||||
startInfo.ArgumentList.Add(baseDirectory);
|
||||
startInfo.ArgumentList.Add(plan.TransactionId);
|
||||
foreach (string argument in args)
|
||||
{
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
_ = Process.Start(startInfo)
|
||||
?? throw new LauncherUpdateException(
|
||||
"The launcher self-update helper could not be started.");
|
||||
return new SelfUpdateStartupResult(true, 0, []);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<int> RunHelperAsync(
|
||||
|
|
@ -153,6 +176,7 @@ public static class LauncherSelfUpdateBootstrap
|
|||
int parentPid,
|
||||
string targetDirectory,
|
||||
string transactionId,
|
||||
IReadOnlyList<string> publicArguments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
||||
|
|
@ -164,12 +188,15 @@ public static class LauncherSelfUpdateBootstrap
|
|||
"The helper transaction does not match the pending self-update.");
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
if (!PathsEqual(plan.TargetDirectory, targetDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The helper target does not match the pending self-update.");
|
||||
}
|
||||
|
||||
string launcherPath = ClientVersionStore.ResolveContained(
|
||||
targetDirectory,
|
||||
"acdream-launcher" + suffix);
|
||||
GetLauncherFileName(plan.Rid));
|
||||
var startInfo = new ProcessStartInfo(launcherPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
|
|
@ -177,97 +204,184 @@ public static class LauncherSelfUpdateBootstrap
|
|||
};
|
||||
startInfo.ArgumentList.Add(ConfirmArgument);
|
||||
startInfo.ArgumentList.Add(transactionId);
|
||||
|
||||
Process? replacement = null;
|
||||
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
|
||||
bool appliedByThisHelper = false;
|
||||
try
|
||||
foreach (string argument in publicArguments)
|
||||
{
|
||||
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
|
||||
updateLease = manager.Barrier.AcquireExclusive();
|
||||
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
appliedByThisHelper = true;
|
||||
replacement = Process.Start(startInfo)
|
||||
?? throw new LauncherUpdateException(
|
||||
"The updated launcher could not be started.");
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
|
||||
while (!manager.IsConfirmed(transactionId))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
replacement.HasExited
|
||||
? $"The updated launcher exited with code {replacement.ExitCode} "
|
||||
+ "before confirming startup."
|
||||
: "The updated launcher did not confirm startup in time.");
|
||||
}
|
||||
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await manager.CompleteConfirmedAsync(
|
||||
transactionId,
|
||||
targetDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return 0;
|
||||
startInfo.ArgumentList.Add(argument);
|
||||
}
|
||||
catch
|
||||
|
||||
await WaitForParentExitAsync(parentPid, cancellationToken).ConfigureAwait(false);
|
||||
if (!manager.Barrier.TryAcquireExclusive(
|
||||
out UpdateSessionBarrier.ExclusiveLease? updateLease))
|
||||
{
|
||||
if (replacement is { HasExited: false })
|
||||
// Do not restart the canonical launcher: it would immediately see
|
||||
// the same staged plan and create an unbounded helper loop.
|
||||
return DeferredLeaseExitCode;
|
||||
}
|
||||
|
||||
using (UpdateSessionBarrier.ExclusiveLease lease = updateLease
|
||||
?? throw new InvalidOperationException("Exclusive update lease is missing."))
|
||||
{
|
||||
plan = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException(
|
||||
"The helper found no pending self-update after acquiring the lease.");
|
||||
if (!string.Equals(
|
||||
plan.TransactionId,
|
||||
transactionId,
|
||||
StringComparison.Ordinal)
|
||||
|| !PathsEqual(plan.TargetDirectory, targetDirectory))
|
||||
{
|
||||
replacement.Kill(entireProcessTree: true);
|
||||
await replacement.WaitForExitAsync(CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update changed before the helper acquired its lease.");
|
||||
}
|
||||
|
||||
_ = manager.CleanupOwnedResidueUnderLease(
|
||||
plan,
|
||||
targetDirectory,
|
||||
lease);
|
||||
Process? replacement = null;
|
||||
bool appliedByThisHelper = false;
|
||||
try
|
||||
{
|
||||
if (appliedByThisHelper)
|
||||
plan = await manager.ApplyPendingAsync(targetDirectory, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
appliedByThisHelper = true;
|
||||
replacement = Process.Start(startInfo)
|
||||
?? throw new LauncherUpdateException(
|
||||
"The updated launcher could not be started.");
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + ConfirmationTimeout;
|
||||
while (!manager.IsConfirmed(transactionId))
|
||||
{
|
||||
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (pending?.State == SelfUpdatePlanState.Applying)
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (replacement.HasExited || DateTimeOffset.UtcNow >= deadline)
|
||||
{
|
||||
_ = await manager.RecoverApplyingAsync(
|
||||
targetDirectory,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
_ = await manager.RollbackAwaitingConfirmationAsync(
|
||||
targetDirectory,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
throw new LauncherUpdateException(
|
||||
replacement.HasExited
|
||||
? $"The updated launcher exited with code {replacement.ExitCode} "
|
||||
+ "before confirming startup."
|
||||
: "The updated launcher did not confirm startup in time.");
|
||||
}
|
||||
|
||||
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await manager.CompleteConfirmedAsync(
|
||||
transactionId,
|
||||
targetDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Do not start an executable from an ambiguous half-applied
|
||||
// state. A subsequent startup replays the durable journal.
|
||||
return 75;
|
||||
}
|
||||
if (replacement is { HasExited: false })
|
||||
{
|
||||
replacement.Kill(entireProcessTree: true);
|
||||
await replacement.WaitForExitAsync(CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
var restored = new ProcessStartInfo(launcherPath)
|
||||
try
|
||||
{
|
||||
if (appliedByThisHelper)
|
||||
{
|
||||
SelfUpdatePlan? pending = await manager.LoadPendingAsync(
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (pending?.State == SelfUpdatePlanState.Applying)
|
||||
{
|
||||
_ = await manager.RecoverApplyingAsync(
|
||||
targetDirectory,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
else if (pending?.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
_ = await manager.RollbackAwaitingConfirmationAsync(
|
||||
targetDirectory,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// An ambiguous state must not start either executable.
|
||||
return 75;
|
||||
}
|
||||
|
||||
var restored = new ProcessStartInfo(launcherPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||
};
|
||||
restored.ArgumentList.Add(DeferredArgument);
|
||||
foreach (string argument in publicArguments)
|
||||
{
|
||||
restored.ArgumentList.Add(argument);
|
||||
}
|
||||
|
||||
_ = Process.Start(restored);
|
||||
return 74;
|
||||
}
|
||||
finally
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||
};
|
||||
_ = Process.Start(restored);
|
||||
return 74;
|
||||
}
|
||||
finally
|
||||
{
|
||||
replacement?.Dispose();
|
||||
updateLease?.Dispose();
|
||||
replacement?.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task FinishConfirmedCleanupAsync(
|
||||
LauncherSelfUpdateManager manager,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DateTimeOffset deadline = DateTimeOffset.UtcNow + CleanupTimeout;
|
||||
do
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
if (manager.Barrier.TryAcquireExclusive(
|
||||
out UpdateSessionBarrier.ExclusiveLease? lease))
|
||||
{
|
||||
using (UpdateSessionBarrier.ExclusiveLease acquiredLease = lease
|
||||
?? throw new InvalidOperationException(
|
||||
"Exclusive cleanup lease is missing."))
|
||||
{
|
||||
SelfUpdatePlan? pending = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (pending is
|
||||
{
|
||||
State: SelfUpdatePlanState.AwaitingConfirmation,
|
||||
}
|
||||
&& manager.IsConfirmed(pending.TransactionId))
|
||||
{
|
||||
await manager.CompleteConfirmedAsync(
|
||||
pending.TransactionId,
|
||||
targetDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
pending = null;
|
||||
}
|
||||
|
||||
if (manager.CleanupOwnedResidueUnderLease(
|
||||
pending,
|
||||
targetDirectory,
|
||||
acquiredLease))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(50, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
while (DateTimeOffset.UtcNow < deadline);
|
||||
}
|
||||
|
||||
private static string GetLauncherFileName(string rid) =>
|
||||
"acdream-launcher"
|
||||
+ (rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty);
|
||||
|
||||
private static async Task WaitForParentExitAsync(
|
||||
int parentPid,
|
||||
CancellationToken cancellationToken)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -292,9 +292,6 @@ public sealed class LauncherUpdater : ILauncherUpdater
|
|||
try
|
||||
{
|
||||
RefuseRunningSessions();
|
||||
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||
_versions.Barrier.AcquireExclusive();
|
||||
RefuseRunningSessions();
|
||||
if (check.Manifest.Version <= _launcherVersion)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
|
|
|
|||
38
src/AcDream.Launcher.Core/Updates/PortablePathRules.cs
Normal file
38
src/AcDream.Launcher.Core/Updates/PortablePathRules.cs
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
/// <summary>
|
||||
/// Host-independent path rules for payloads that must remain safe when moved
|
||||
/// between Linux and Windows. Windows device aliases are rejected on every
|
||||
/// host so a release cannot verify on one platform and become ambiguous on
|
||||
/// another.
|
||||
/// </summary>
|
||||
internal static class PortablePathRules
|
||||
{
|
||||
public static bool IsWindowsDeviceName(string segment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(segment);
|
||||
string stem = segment.Split('.')[0];
|
||||
if (stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("CLOCK$", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("CONIN$", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("CONOUT$", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (stem.Length != 4
|
||||
|| (!stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
||||
&& !stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return stem[3] is >= '1' and <= '9'
|
||||
or '\u00b9'
|
||||
or '\u00b2'
|
||||
or '\u00b3';
|
||||
}
|
||||
}
|
||||
|
|
@ -10,9 +10,11 @@ public interface IReleaseManifestClient
|
|||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strict, bounded reader for the pinned GitHub Releases manifest. HTTP is
|
||||
/// accepted only for a loopback fixture; production and artifact URLs are
|
||||
/// HTTPS-only.
|
||||
/// Strict, bounded reader for the pinned GitHub Releases manifest. Production
|
||||
/// construction is HTTPS-only. The loopback HTTP allowance is available only
|
||||
/// through an internal fixture factory and is never inferred from a URI.
|
||||
/// Redirects are followed manually so every hop is checked before any bytes
|
||||
/// cross that hop.
|
||||
/// </summary>
|
||||
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||
{
|
||||
|
|
@ -20,6 +22,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
public const string GitHubRepository = "acdream";
|
||||
public const int MaximumManifestBytes = 1024 * 1024;
|
||||
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
|
||||
public const int MaximumRedirects = 5;
|
||||
|
||||
public static Uri ProductionManifestUri { get; } = new(
|
||||
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
|
||||
|
|
@ -33,65 +36,103 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly Uri _manifestUri;
|
||||
private readonly bool _allowLoopbackHttp;
|
||||
|
||||
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null)
|
||||
public ReleaseManifestClient(TimeSpan? timeout = null)
|
||||
: this(
|
||||
ProductionManifestUri,
|
||||
allowLoopbackHttp: false,
|
||||
CreateRedirectDisabledHandler(),
|
||||
timeout)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_ownsHttpClient = httpClient is null;
|
||||
_manifestUri = manifestUri ?? ProductionManifestUri;
|
||||
RequireSecureOrLoopback(_manifestUri, "manifest");
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||
}
|
||||
}
|
||||
|
||||
private ReleaseManifestClient(
|
||||
Uri manifestUri,
|
||||
bool allowLoopbackHttp,
|
||||
HttpMessageHandler handler,
|
||||
TimeSpan? timeout)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifestUri);
|
||||
ArgumentNullException.ThrowIfNull(handler);
|
||||
_manifestUri = manifestUri;
|
||||
_allowLoopbackHttp = allowLoopbackHttp;
|
||||
RequireTransport(_manifestUri, "manifest", _allowLoopbackHttp);
|
||||
_httpClient = new HttpClient(handler, disposeHandler: true)
|
||||
{
|
||||
Timeout = timeout ?? TimeSpan.FromSeconds(15),
|
||||
};
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||
}
|
||||
|
||||
internal static ReleaseManifestClient CreateLoopbackFixture(
|
||||
Uri manifestUri,
|
||||
TimeSpan? timeout = null) => new(
|
||||
manifestUri,
|
||||
allowLoopbackHttp: true,
|
||||
CreateRedirectDisabledHandler(),
|
||||
timeout);
|
||||
|
||||
internal static ReleaseManifestClient CreateForTransportTest(
|
||||
Uri manifestUri,
|
||||
bool allowLoopbackHttp,
|
||||
HttpMessageHandler handler) => new(
|
||||
manifestUri,
|
||||
allowLoopbackHttp,
|
||||
handler,
|
||||
TimeSpan.FromSeconds(15));
|
||||
|
||||
public async Task<ReleaseManifest> FetchAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
_manifestUri,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
Uri finalUri = response.RequestMessage?.RequestUri ?? _manifestUri;
|
||||
RequireSecureOrLoopback(finalUri, "manifest redirect");
|
||||
if (response.Content.Headers.ContentLength is long contentLength
|
||||
&& contentLength > MaximumManifestBytes)
|
||||
Uri current = _manifestUri;
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (int redirectCount = 0;;)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||
}
|
||||
|
||||
await using Stream input = await response.Content
|
||||
.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (output.Length + read > MaximumManifestBytes)
|
||||
RequireTransport(current, "manifest redirect", _allowLoopbackHttp);
|
||||
if (!visited.Add(current.AbsoluteUri))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||
"The release manifest redirect chain contains a loop.");
|
||||
}
|
||||
|
||||
output.Write(buffer, 0, read);
|
||||
}
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
using HttpResponseMessage response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (IsRedirect(response.StatusCode))
|
||||
{
|
||||
if (redirectCount >= MaximumRedirects)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest exceeded {MaximumRedirects} redirects.");
|
||||
}
|
||||
|
||||
return Parse(output.ToArray());
|
||||
Uri? location = response.Headers.Location;
|
||||
if (location is null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The release manifest redirect has no Location header.");
|
||||
}
|
||||
|
||||
Uri next = location.IsAbsoluteUri
|
||||
? location
|
||||
: new Uri(current, location);
|
||||
RequireTransport(next, "manifest redirect", _allowLoopbackHttp);
|
||||
current = next;
|
||||
redirectCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
response.EnsureSuccessStatusCode();
|
||||
return await ReadAndParseAsync(response, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
|
|
@ -112,7 +153,9 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8)
|
||||
internal static ReleaseManifest Parse(
|
||||
ReadOnlySpan<byte> utf8,
|
||||
bool allowLoopbackHttpArtifacts = false)
|
||||
{
|
||||
try
|
||||
{
|
||||
|
|
@ -127,7 +170,7 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
RejectDuplicateProperties(document.RootElement, "$" );
|
||||
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
|
||||
SerializerOptions);
|
||||
return Validate(value);
|
||||
return Validate(value, allowLoopbackHttpArtifacts);
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
|
|
@ -143,26 +186,68 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
public void Dispose() => _httpClient.Dispose();
|
||||
|
||||
internal static void RequireSecureOrLoopback(Uri uri, string description)
|
||||
internal static void RequireTransport(
|
||||
Uri uri,
|
||||
string description,
|
||||
bool allowLoopbackHttp)
|
||||
{
|
||||
if (!uri.IsAbsoluteUri
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps
|
||||
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
||||
&& !(allowLoopbackHttp
|
||||
&& uri.Scheme == Uri.UriSchemeHttp
|
||||
&& uri.IsLoopback)))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The {description} URI must use HTTPS (loopback HTTP is test-only).");
|
||||
$"The {description} URI must use HTTPS"
|
||||
+ (allowLoopbackHttp ? " (or fixture-only loopback HTTP)." : "."));
|
||||
}
|
||||
}
|
||||
|
||||
private static ReleaseManifest Validate(ManifestDocument? document)
|
||||
internal static void RequireSecureOrLoopback(Uri uri, string description) =>
|
||||
RequireTransport(uri, description, allowLoopbackHttp: true);
|
||||
|
||||
private async Task<ReleaseManifest> ReadAndParseAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (response.Content.Headers.ContentLength is long contentLength
|
||||
&& contentLength > MaximumManifestBytes)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||
}
|
||||
|
||||
await using Stream input = await response.Content
|
||||
.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
using var output = new MemoryStream();
|
||||
byte[] buffer = new byte[16 * 1024];
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(buffer, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
if (output.Length + read > MaximumManifestBytes)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest is larger than {MaximumManifestBytes} bytes.");
|
||||
}
|
||||
|
||||
output.Write(buffer, 0, read);
|
||||
}
|
||||
|
||||
return Parse(output.ToArray(), _allowLoopbackHttp);
|
||||
}
|
||||
|
||||
private static ReleaseManifest Validate(
|
||||
ManifestDocument? document,
|
||||
bool allowLoopbackHttpArtifacts)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
|
|
@ -187,18 +272,22 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
throw new LauncherUpdateException(
|
||||
"The minimum launcher version cannot exceed the release version.");
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
|
||||
document.Clients,
|
||||
"clients");
|
||||
"clients",
|
||||
allowLoopbackHttpArtifacts);
|
||||
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
|
||||
document.Launchers,
|
||||
"launchers");
|
||||
"launchers",
|
||||
allowLoopbackHttpArtifacts);
|
||||
return new ReleaseManifest(version, minimum, clients, launchers);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
|
||||
Dictionary<string, ArtifactDocument>? artifacts,
|
||||
string field)
|
||||
string field,
|
||||
bool allowLoopbackHttpArtifacts)
|
||||
{
|
||||
if (artifacts is null || artifacts.Count == 0)
|
||||
{
|
||||
|
|
@ -226,7 +315,10 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
$"Manifest payload '{field}.{rid}' has an invalid URL.");
|
||||
}
|
||||
|
||||
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
|
||||
RequireTransport(
|
||||
uri,
|
||||
$"{field}.{rid} artifact",
|
||||
allowLoopbackHttpArtifacts);
|
||||
if (!IsSha256(value.Sha256))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
|
|
@ -250,6 +342,21 @@ public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
|||
internal static bool IsSha256(string? value) =>
|
||||
value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||
|
||||
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
|
||||
HttpStatusCode.MovedPermanently
|
||||
or HttpStatusCode.Found
|
||||
or HttpStatusCode.SeeOther
|
||||
or HttpStatusCode.TemporaryRedirect
|
||||
or HttpStatusCode.PermanentRedirect;
|
||||
|
||||
private static HttpMessageHandler CreateRedirectDisabledHandler() =>
|
||||
new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
};
|
||||
|
||||
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ public sealed class SafeZipExtractor
|
|||
|| segment.Any(character =>
|
||||
char.IsControl(character)
|
||||
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||
|| IsWindowsDeviceName(segment))
|
||||
|| PortablePathRules.IsWindowsDeviceName(segment))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP path '{name}' contains an unsafe segment.");
|
||||
|
|
@ -396,19 +396,6 @@ public sealed class SafeZipExtractor
|
|||
}
|
||||
}
|
||||
|
||||
private static bool IsWindowsDeviceName(string segment)
|
||||
{
|
||||
string stem = segment.Split('.')[0];
|
||||
return stem.Equals("CON", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("PRN", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("AUX", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.Equals("NUL", StringComparison.OrdinalIgnoreCase)
|
||||
|| (stem.Length == 4
|
||||
&& (stem.StartsWith("COM", StringComparison.OrdinalIgnoreCase)
|
||||
|| stem.StartsWith("LPT", StringComparison.OrdinalIgnoreCase))
|
||||
&& stem[3] is >= '1' and <= '9');
|
||||
}
|
||||
|
||||
internal static void TryDeleteDirectory(string path)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -34,7 +34,53 @@ public sealed class UpdateSessionBarrier
|
|||
FileShare.None,
|
||||
"A launcher session or another update transaction is running. "
|
||||
+ "Stop every launcher session before updating.");
|
||||
return new ExclusiveLease(stream);
|
||||
return new ExclusiveLease(this, stream);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Non-blocking startup probe. Contention is an expected "not now"
|
||||
/// result; permission and path failures remain hard errors.
|
||||
/// </summary>
|
||||
public bool TryAcquireExclusive(out ExclusiveLease? lease)
|
||||
{
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(_lockPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The update/session lock path has no parent directory."));
|
||||
try
|
||||
{
|
||||
lease = new ExclusiveLease(
|
||||
this,
|
||||
new FileStream(
|
||||
_lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
FileShare.None,
|
||||
bufferSize: 1,
|
||||
FileOptions.None));
|
||||
return true;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
lease = null;
|
||||
return false;
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The update/session lease could not be opened: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal void RequireOwned(ExclusiveLease lease)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(lease);
|
||||
if (!lease.IsHeldBy(this))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The cleanup operation does not hold this update barrier's exclusive lease.");
|
||||
}
|
||||
}
|
||||
|
||||
private FileStream Open(FileShare share, string refusal)
|
||||
|
|
@ -76,9 +122,18 @@ public sealed class UpdateSessionBarrier
|
|||
|
||||
public sealed class ExclusiveLease : IDisposable
|
||||
{
|
||||
private readonly UpdateSessionBarrier _owner;
|
||||
private FileStream? _stream;
|
||||
|
||||
internal ExclusiveLease(FileStream stream) => _stream = stream;
|
||||
internal ExclusiveLease(UpdateSessionBarrier owner, FileStream stream)
|
||||
{
|
||||
_owner = owner;
|
||||
_stream = stream;
|
||||
}
|
||||
|
||||
internal bool IsHeldBy(UpdateSessionBarrier owner) =>
|
||||
ReferenceEquals(_owner, owner)
|
||||
&& Volatile.Read(ref _stream) is not null;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
using System.Buffers;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
|
@ -55,15 +56,11 @@ public sealed class VerifiedArtifactDownloader
|
|||
bool ownsDestination = false;
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
using HttpResponseMessage response = await SendWithValidatedRedirectsAsync(
|
||||
artifact.Url,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
response.EnsureSuccessStatusCode();
|
||||
ReleaseManifestClient.RequireSecureOrLoopback(
|
||||
response.RequestMessage?.RequestUri ?? artifact.Url,
|
||||
"artifact redirect");
|
||||
if (response.Content.Headers.ContentLength is long contentLength
|
||||
&& contentLength != artifact.Size)
|
||||
{
|
||||
|
|
@ -183,6 +180,86 @@ public sealed class VerifiedArtifactDownloader
|
|||
}
|
||||
}
|
||||
|
||||
private async Task<HttpResponseMessage> SendWithValidatedRedirectsAsync(
|
||||
Uri initialUri,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
bool allowLoopbackHttp = initialUri.Scheme == Uri.UriSchemeHttp
|
||||
&& initialUri.IsLoopback;
|
||||
Uri current = initialUri;
|
||||
var visited = new HashSet<string>(StringComparer.Ordinal);
|
||||
for (int redirectCount = 0;;)
|
||||
{
|
||||
ReleaseManifestClient.RequireTransport(
|
||||
current,
|
||||
"artifact redirect",
|
||||
allowLoopbackHttp);
|
||||
if (!visited.Add(current.AbsoluteUri))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The release artifact redirect chain contains a loop.");
|
||||
}
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, current);
|
||||
HttpResponseMessage response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Uri effectiveUri = response.RequestMessage?.RequestUri ?? current;
|
||||
if (!Uri.Equals(effectiveUri, current))
|
||||
{
|
||||
response.Dispose();
|
||||
throw new LauncherUpdateException(
|
||||
"The artifact HTTP transport followed an automatic redirect; "
|
||||
+ "every redirect must be validated before it is requested.");
|
||||
}
|
||||
|
||||
if (!IsRedirect(response.StatusCode))
|
||||
{
|
||||
return response;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (redirectCount >= ReleaseManifestClient.MaximumRedirects)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release artifact exceeded "
|
||||
+ $"{ReleaseManifestClient.MaximumRedirects} redirects.");
|
||||
}
|
||||
|
||||
Uri? location = response.Headers.Location;
|
||||
if (location is null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The release artifact redirect has no Location header.");
|
||||
}
|
||||
|
||||
Uri next = location.IsAbsoluteUri
|
||||
? location
|
||||
: new Uri(current, location);
|
||||
ReleaseManifestClient.RequireTransport(
|
||||
next,
|
||||
"artifact redirect",
|
||||
allowLoopbackHttp);
|
||||
current = next;
|
||||
redirectCount++;
|
||||
}
|
||||
finally
|
||||
{
|
||||
response.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsRedirect(HttpStatusCode statusCode) => statusCode is
|
||||
HttpStatusCode.MovedPermanently
|
||||
or HttpStatusCode.Found
|
||||
or HttpStatusCode.SeeOther
|
||||
or HttpStatusCode.TemporaryRedirect
|
||||
or HttpStatusCode.PermanentRedirect;
|
||||
|
||||
internal static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
|
|
|
|||
|
|
@ -14,6 +14,10 @@
|
|||
<PublishBakeTool Condition="'$(PublishBakeTool)' == ''">true</PublishBakeTool>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="AcDream.Launcher.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="12.1.1" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.1.1" />
|
||||
|
|
|
|||
|
|
@ -16,8 +16,7 @@ public sealed partial class App : Application
|
|||
{
|
||||
private LauncherOrchestrator? _orchestrator;
|
||||
private LauncherWindowViewModel? _viewModel;
|
||||
private HttpClient? _updateHttpClient;
|
||||
private ReleaseManifestClient? _manifestClient;
|
||||
private LauncherUpdateComposition? _updateComposition;
|
||||
|
||||
public override void Initialize() => AvaloniaXamlLoader.Load(this);
|
||||
|
||||
|
|
@ -52,38 +51,27 @@ public sealed partial class App : Application
|
|||
$"Client content verification failed: {ex.Message}");
|
||||
}
|
||||
|
||||
var clientVersions = new ClientVersionStore(paths);
|
||||
_ = clientVersions.LoadAndRecoverAsync(rid)
|
||||
.GetAwaiter()
|
||||
.GetResult();
|
||||
LauncherUpdateComposition updates = LauncherUpdateComposition.Create(
|
||||
paths,
|
||||
rid,
|
||||
GetLauncherVersion(),
|
||||
AppContext.BaseDirectory,
|
||||
() => _orchestrator?.GetSnapshot().Sessions.Any(session => session.IsActive)
|
||||
== true);
|
||||
_updateComposition = updates;
|
||||
|
||||
_orchestrator = new LauncherOrchestrator(
|
||||
profiles,
|
||||
paths,
|
||||
LauncherExecutableSet.FromCurrentVersionStore(clientVersions),
|
||||
updates.Executables,
|
||||
verification.Record,
|
||||
installationStatus: verification.Status,
|
||||
updateSessionBarrier: clientVersions.Barrier);
|
||||
_updateHttpClient = new HttpClient();
|
||||
_updateHttpClient.Timeout = TimeSpan.FromSeconds(15);
|
||||
_updateHttpClient.DefaultRequestHeaders.UserAgent.ParseAdd(
|
||||
"acdream-launcher/1");
|
||||
_manifestClient = new ReleaseManifestClient(_updateHttpClient);
|
||||
var selfUpdates = new LauncherSelfUpdateManager(paths, _updateHttpClient);
|
||||
var updater = new LauncherUpdater(
|
||||
_manifestClient,
|
||||
_updateHttpClient,
|
||||
clientVersions,
|
||||
selfUpdates,
|
||||
GetLauncherVersion(),
|
||||
rid,
|
||||
AppContext.BaseDirectory,
|
||||
() => _orchestrator.GetSnapshot().Sessions.Any(session => session.IsActive));
|
||||
updateSessionBarrier: updates.Versions.Barrier);
|
||||
_viewModel = new LauncherWindowViewModel(
|
||||
_orchestrator,
|
||||
new AvaloniaUiDispatcher(),
|
||||
installer,
|
||||
updater);
|
||||
updates.Updater);
|
||||
_viewModel.Initialize();
|
||||
|
||||
desktop.MainWindow = new MainWindow
|
||||
|
|
@ -100,12 +88,10 @@ public sealed partial class App : Application
|
|||
{
|
||||
_viewModel?.Dispose();
|
||||
_orchestrator?.Dispose();
|
||||
_manifestClient?.Dispose();
|
||||
_updateHttpClient?.Dispose();
|
||||
_updateComposition?.Dispose();
|
||||
_viewModel = null;
|
||||
_orchestrator = null;
|
||||
_manifestClient = null;
|
||||
_updateHttpClient = null;
|
||||
_updateComposition = null;
|
||||
}
|
||||
|
||||
private static LauncherVersion GetLauncherVersion()
|
||||
|
|
|
|||
128
src/AcDream.Launcher/LauncherUpdateComposition.cs
Normal file
128
src/AcDream.Launcher/LauncherUpdateComposition.cs
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
using System.Net;
|
||||
using System.Security;
|
||||
using System.Text.Json;
|
||||
using AcDream.Launcher.Core.Orchestration;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Launcher.ViewModels;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher;
|
||||
|
||||
/// <summary>
|
||||
/// Testable startup transaction for versioned-client/update services. Storage
|
||||
/// failures produce a fail-closed executable resolver and an unavailable UI
|
||||
/// projection; they do not abort profile/installer window construction.
|
||||
/// </summary>
|
||||
internal sealed class LauncherUpdateComposition : IDisposable
|
||||
{
|
||||
private readonly HttpClient? _artifactClient;
|
||||
private readonly ReleaseManifestClient? _manifestClient;
|
||||
|
||||
private LauncherUpdateComposition(
|
||||
ClientVersionStore versions,
|
||||
LauncherExecutableSet executables,
|
||||
ILauncherUpdater updater,
|
||||
HttpClient? artifactClient,
|
||||
ReleaseManifestClient? manifestClient)
|
||||
{
|
||||
Versions = versions;
|
||||
Executables = executables;
|
||||
Updater = updater;
|
||||
_artifactClient = artifactClient;
|
||||
_manifestClient = manifestClient;
|
||||
}
|
||||
|
||||
public ClientVersionStore Versions { get; }
|
||||
|
||||
public LauncherExecutableSet Executables { get; }
|
||||
|
||||
public ILauncherUpdater Updater { get; }
|
||||
|
||||
public static LauncherUpdateComposition Create(
|
||||
ApplicationPathSet paths,
|
||||
string rid,
|
||||
LauncherVersion launcherVersion,
|
||||
string launcherTargetDirectory,
|
||||
Func<bool> hasRunningSessions,
|
||||
Func<ClientVersionStore, string, ClientVersionResolution>? initialize = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
ArgumentNullException.ThrowIfNull(launcherVersion);
|
||||
ArgumentNullException.ThrowIfNull(hasRunningSessions);
|
||||
var versions = new ClientVersionStore(paths);
|
||||
HttpClient? artifactClient = null;
|
||||
ReleaseManifestClient? manifestClient = null;
|
||||
try
|
||||
{
|
||||
_ = initialize is null
|
||||
? versions.LoadAndRecoverAsync(rid).GetAwaiter().GetResult()
|
||||
: initialize(versions, rid);
|
||||
artifactClient = new HttpClient(
|
||||
new HttpClientHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
UseCookies = false,
|
||||
AutomaticDecompression = DecompressionMethods.None,
|
||||
},
|
||||
disposeHandler: true)
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(15),
|
||||
};
|
||||
artifactClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||
manifestClient = new ReleaseManifestClient(TimeSpan.FromSeconds(15));
|
||||
var selfUpdates = new LauncherSelfUpdateManager(paths, artifactClient);
|
||||
var updater = new LauncherUpdater(
|
||||
manifestClient,
|
||||
artifactClient,
|
||||
versions,
|
||||
selfUpdates,
|
||||
launcherVersion,
|
||||
rid,
|
||||
launcherTargetDirectory,
|
||||
hasRunningSessions);
|
||||
return new LauncherUpdateComposition(
|
||||
versions,
|
||||
LauncherExecutableSet.FromCurrentVersionStore(versions),
|
||||
updater,
|
||||
artifactClient,
|
||||
manifestClient);
|
||||
}
|
||||
catch (Exception ex) when (IsStorageFailure(ex))
|
||||
{
|
||||
manifestClient?.Dispose();
|
||||
artifactClient?.Dispose();
|
||||
string status = "Versioned client update storage is unavailable: "
|
||||
+ (string.IsNullOrWhiteSpace(ex.Message)
|
||||
? "the storage operation failed."
|
||||
: ex.Message);
|
||||
var resolution = new ClientVersionResolution(
|
||||
ClientVersionState.Invalid,
|
||||
status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
return new LauncherUpdateComposition(
|
||||
versions,
|
||||
LauncherExecutableSet.Unavailable(status),
|
||||
new UnavailableLauncherUpdater(status, resolution),
|
||||
artifactClient: null,
|
||||
manifestClient: null);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_manifestClient?.Dispose();
|
||||
_artifactClient?.Dispose();
|
||||
}
|
||||
|
||||
private static bool IsStorageFailure(Exception exception) => exception is
|
||||
IOException
|
||||
or UnauthorizedAccessException
|
||||
or SecurityException
|
||||
or JsonException
|
||||
or FormatException
|
||||
or NotSupportedException
|
||||
or LauncherUpdateException;
|
||||
}
|
||||
|
|
@ -479,42 +479,52 @@ public sealed class LauncherUpdateViewModel : ObservableObject, IDisposable
|
|||
|
||||
internal sealed class UnavailableLauncherUpdater : ILauncherUpdater
|
||||
{
|
||||
private static readonly ClientVersionResolution Missing = new(
|
||||
ClientVersionState.Missing,
|
||||
"Versioned client updater is unavailable.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
private readonly ClientVersionResolution _resolution;
|
||||
private readonly string _status;
|
||||
|
||||
public ClientVersionResolution CurrentClient => Missing;
|
||||
public UnavailableLauncherUpdater(
|
||||
string status = "Versioned client updater is unavailable.",
|
||||
ClientVersionResolution? resolution = null)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(status);
|
||||
_status = status;
|
||||
_resolution = resolution ?? new ClientVersionResolution(
|
||||
ClientVersionState.Invalid,
|
||||
status,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
}
|
||||
|
||||
public ClientVersionResolution CurrentClient => _resolution;
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(Missing);
|
||||
Task.FromResult(_resolution);
|
||||
|
||||
public Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<LauncherUpdateCheckResult>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
new LauncherUpdateException(_status));
|
||||
|
||||
public Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ClientVersionResolution>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
new LauncherUpdateException(_status));
|
||||
|
||||
public Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<SelfUpdateStageResult>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
new LauncherUpdateException(_status));
|
||||
|
||||
public Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromException<ClientVersionResolution>(
|
||||
new LauncherUpdateException("Versioned client updater is unavailable."));
|
||||
new LauncherUpdateException(_status));
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue