fix(launcher): harden updater crash recovery
This commit is contained in:
parent
2d2a5b5046
commit
1955ca8ab5
27 changed files with 2714 additions and 544 deletions
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue