feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
|
|
@ -1,18 +1,19 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves and validates the co-deployed graphical/headless hosts. LA10 will
|
||||
/// replace the directory lookup with its versioned-current resolver; until
|
||||
/// then a missing host disables the corresponding action instead of deferring
|
||||
/// failure until process creation.
|
||||
/// Resolves and validates the graphical/headless hosts. Production uses the
|
||||
/// verified <c>DataDirectory/app/current.json</c> resolver; the explicit-path
|
||||
/// constructor remains the injectable test seam.
|
||||
/// </summary>
|
||||
public sealed class LauncherExecutableSet
|
||||
{
|
||||
private readonly Func<string, bool> _fileExists;
|
||||
private readonly Func<string, bool> _hasUnixExecutePermission;
|
||||
private readonly Func<ExecutablePaths> _resolve;
|
||||
|
||||
public LauncherExecutableSet(
|
||||
string graphicalHostPath,
|
||||
|
|
@ -23,25 +24,50 @@ public sealed class LauncherExecutableSet
|
|||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(graphicalHostPath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(headlessHostPath);
|
||||
GraphicalHostPath = graphicalHostPath;
|
||||
HeadlessHostPath = headlessHostPath;
|
||||
WorkingDirectory = workingDirectory;
|
||||
string graphical = graphicalHostPath;
|
||||
string headless = headlessHostPath;
|
||||
_resolve = () => new ExecutablePaths(graphical, headless, workingDirectory);
|
||||
_fileExists = fileExists ?? File.Exists;
|
||||
_hasUnixExecutePermission =
|
||||
hasUnixExecutePermission ?? HasUnixExecutePermission;
|
||||
}
|
||||
|
||||
public string GraphicalHostPath { get; }
|
||||
private LauncherExecutableSet(
|
||||
Func<ExecutablePaths> resolve,
|
||||
Func<string, bool>? fileExists = null,
|
||||
Func<string, bool>? hasUnixExecutePermission = null)
|
||||
{
|
||||
_resolve = resolve ?? throw new ArgumentNullException(nameof(resolve));
|
||||
_fileExists = fileExists ?? File.Exists;
|
||||
_hasUnixExecutePermission =
|
||||
hasUnixExecutePermission ?? HasUnixExecutePermission;
|
||||
}
|
||||
|
||||
public string HeadlessHostPath { get; }
|
||||
public string GraphicalHostPath => _resolve().GraphicalHostPath;
|
||||
|
||||
public string? WorkingDirectory { get; }
|
||||
public string HeadlessHostPath => _resolve().HeadlessHostPath;
|
||||
|
||||
public string? WorkingDirectory => _resolve().WorkingDirectory;
|
||||
|
||||
public LauncherCapability GetAvailability(LaunchMode mode)
|
||||
{
|
||||
ExecutablePaths paths;
|
||||
try
|
||||
{
|
||||
paths = _resolve();
|
||||
}
|
||||
catch (Exception ex) when (ex is LauncherUpdateException
|
||||
or InvalidOperationException
|
||||
or IOException
|
||||
or UnauthorizedAccessException)
|
||||
{
|
||||
return LauncherCapability.Unavailable(
|
||||
$"The active versioned client is unavailable: {ex.Message}");
|
||||
}
|
||||
|
||||
string path = mode == LaunchMode.Headless
|
||||
? HeadlessHostPath
|
||||
: GraphicalHostPath;
|
||||
? paths.HeadlessHostPath
|
||||
: paths.GraphicalHostPath;
|
||||
string host = mode == LaunchMode.Headless
|
||||
? "headless host"
|
||||
: "graphical client";
|
||||
|
|
@ -68,27 +94,27 @@ public sealed class LauncherExecutableSet
|
|||
string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
RequireAvailable(mode);
|
||||
ExecutablePaths paths = RequireAvailable(mode);
|
||||
|
||||
return mode == LaunchMode.Headless
|
||||
? new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
paths.HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory)
|
||||
paths.WorkingDirectory)
|
||||
: new LauncherProcessSpec(
|
||||
GraphicalHostPath,
|
||||
paths.GraphicalHostPath,
|
||||
["--session-config", configFilePath],
|
||||
WorkingDirectory);
|
||||
paths.WorkingDirectory);
|
||||
}
|
||||
|
||||
public LauncherProcessSpec CreateProbeSpec(string configFilePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(configFilePath);
|
||||
RequireAvailable(LaunchMode.Headless);
|
||||
ExecutablePaths paths = RequireAvailable(LaunchMode.Headless);
|
||||
return new LauncherProcessSpec(
|
||||
HeadlessHostPath,
|
||||
paths.HeadlessHostPath,
|
||||
["--config", configFilePath],
|
||||
WorkingDirectory);
|
||||
paths.WorkingDirectory);
|
||||
}
|
||||
|
||||
public static LauncherExecutableSet FromDirectory(string directory)
|
||||
|
|
@ -102,7 +128,28 @@ public sealed class LauncherExecutableSet
|
|||
fullDirectory);
|
||||
}
|
||||
|
||||
private void RequireAvailable(LaunchMode mode)
|
||||
/// <summary>
|
||||
/// Dynamic production resolver. The store cache is admitted only after a
|
||||
/// strict startup/update verification, and a pointer swap changes the
|
||||
/// binaries selected for the next session without replacing LA9 content.
|
||||
/// </summary>
|
||||
public static LauncherExecutableSet FromCurrentVersionStore(
|
||||
ClientVersionStore store)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(store);
|
||||
return new LauncherExecutableSet(() =>
|
||||
{
|
||||
ClientVersionResolution resolution = store.CachedResolution;
|
||||
if (!resolution.IsVerified || resolution.Directory is null)
|
||||
{
|
||||
throw new LauncherUpdateException(resolution.Status);
|
||||
}
|
||||
|
||||
return FromDirectoryPaths(resolution.Directory);
|
||||
});
|
||||
}
|
||||
|
||||
private ExecutablePaths RequireAvailable(LaunchMode mode)
|
||||
{
|
||||
LauncherCapability capability = GetAvailability(mode);
|
||||
if (!capability.IsAvailable)
|
||||
|
|
@ -110,6 +157,18 @@ public sealed class LauncherExecutableSet
|
|||
throw new LauncherOperationException(
|
||||
capability.Reason ?? "The selected launcher host is unavailable.");
|
||||
}
|
||||
|
||||
return _resolve();
|
||||
}
|
||||
|
||||
private static ExecutablePaths FromDirectoryPaths(string directory)
|
||||
{
|
||||
string fullDirectory = Path.GetFullPath(directory);
|
||||
string executableSuffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
return new ExecutablePaths(
|
||||
Path.Combine(fullDirectory, "AcDream.App" + executableSuffix),
|
||||
Path.Combine(fullDirectory, "acdream-headless" + executableSuffix),
|
||||
fullDirectory);
|
||||
}
|
||||
|
||||
private static bool HasUnixExecutePermission(string path)
|
||||
|
|
@ -134,4 +193,9 @@ public sealed class LauncherExecutableSet
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ExecutablePaths(
|
||||
string GraphicalHostPath,
|
||||
string HeadlessHostPath,
|
||||
string? WorkingDirectory);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using AcDream.Launcher.Core.Launching;
|
||||
using AcDream.Launcher.Core.Profiles;
|
||||
using AcDream.Launcher.Core.Status;
|
||||
using AcDream.Launcher.Core.Updates;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Orchestration;
|
||||
|
|
@ -26,6 +27,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
private readonly ILauncherProcessSupervisorFactory _supervisorFactory;
|
||||
private readonly IStatusEventSourceFactory _statusSourceFactory;
|
||||
private readonly Func<string> _sessionIdFactory;
|
||||
private readonly UpdateSessionBarrier _updateSessionBarrier;
|
||||
private readonly List<ManagedActivity> _activities = [];
|
||||
|
||||
private LauncherInstallRecord? _installRecord;
|
||||
|
|
@ -42,7 +44,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
ILauncherProcessSupervisorFactory? supervisorFactory = null,
|
||||
IStatusEventSourceFactory? statusSourceFactory = null,
|
||||
Func<string>? sessionIdFactory = null,
|
||||
string? installationStatus = null)
|
||||
string? installationStatus = null,
|
||||
UpdateSessionBarrier? updateSessionBarrier = null)
|
||||
{
|
||||
_profileStore = profileStore ?? throw new ArgumentNullException(nameof(profileStore));
|
||||
_paths = paths ?? throw new ArgumentNullException(nameof(paths));
|
||||
|
|
@ -53,6 +56,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
_supervisorFactory = supervisorFactory ?? new LauncherProcessSupervisorFactory();
|
||||
_statusSourceFactory = statusSourceFactory ?? new StatusFileTailerFactory();
|
||||
_sessionIdFactory = sessionIdFactory ?? CreateSessionId;
|
||||
_updateSessionBarrier = updateSessionBarrier
|
||||
?? new UpdateSessionBarrier(paths.DataDirectory);
|
||||
_installationStatus = installationStatus
|
||||
?? (installRecord is null
|
||||
? FirstRunRequired
|
||||
|
|
@ -629,10 +634,18 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
{
|
||||
ILauncherProcessSupervisor? supervisor = null;
|
||||
string? password = request.Password;
|
||||
bool hostStarted = false;
|
||||
try
|
||||
{
|
||||
request.Cancellation.Token.ThrowIfCancellationRequested();
|
||||
|
||||
UpdateSessionBarrier.SessionLease sessionLease =
|
||||
_updateSessionBarrier.AcquireSession();
|
||||
lock (_gate)
|
||||
{
|
||||
request.Activity.UpdateSessionLease = sessionLease;
|
||||
}
|
||||
|
||||
ComposedSessionConfig composed = request.IsProbe
|
||||
? _configService.ComposeProbeAndWrite(
|
||||
request.Server,
|
||||
|
|
@ -674,6 +687,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
request.Activity.LaunchMode!.Value,
|
||||
composed.ConfigFilePath);
|
||||
supervisor.Start(processSpec, password);
|
||||
hostStarted = true;
|
||||
|
||||
request.Password = null;
|
||||
password = null;
|
||||
|
|
@ -728,6 +742,10 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
finally
|
||||
{
|
||||
request.Password = null;
|
||||
if (!hostStarted)
|
||||
{
|
||||
ReleaseUpdateSessionLease(request.Activity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -735,6 +753,7 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
ManagedActivity activity,
|
||||
LauncherSessionState processState)
|
||||
{
|
||||
UpdateSessionBarrier.SessionLease? sessionLease = null;
|
||||
try
|
||||
{
|
||||
lock (_gate)
|
||||
|
|
@ -774,10 +793,13 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
{
|
||||
activity.Status = activity.HostTerminalStatus;
|
||||
}
|
||||
sessionLease = activity.UpdateSessionLease;
|
||||
activity.UpdateSessionLease = null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
sessionLease?.Dispose();
|
||||
RaiseStateChanged();
|
||||
}
|
||||
catch
|
||||
|
|
@ -1192,6 +1214,15 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
activity.Supervisor.Dispose();
|
||||
activity.Supervisor = null;
|
||||
}
|
||||
|
||||
ReleaseUpdateSessionLease(activity);
|
||||
}
|
||||
|
||||
private static void ReleaseUpdateSessionLease(ManagedActivity activity)
|
||||
{
|
||||
UpdateSessionBarrier.SessionLease? lease =
|
||||
Interlocked.Exchange(ref activity.UpdateSessionLease, null);
|
||||
lease?.Dispose();
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
|
|
@ -1252,6 +1283,8 @@ public sealed class LauncherOrchestrator : ILauncherOrchestrator
|
|||
|
||||
public CancellationTokenSource? StartCancellation { get; set; }
|
||||
|
||||
public UpdateSessionBarrier.SessionLease? UpdateSessionLease;
|
||||
|
||||
public object StatusReadGate { get; } = new();
|
||||
|
||||
public bool IsActive => State is not (
|
||||
|
|
|
|||
84
src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs
Normal file
84
src/AcDream.Launcher.Core/Updates/AtomicJsonFile.cs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
using System.Text.Json;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
internal static class AtomicJsonFile
|
||||
{
|
||||
internal static async Task WriteAsync<T>(
|
||||
string path,
|
||||
T value,
|
||||
JsonSerializerOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
string directory = Path.GetDirectoryName(fullPath)
|
||||
?? throw new InvalidOperationException("The JSON path has no parent directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporaryPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(
|
||||
temporaryPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
16 * 1024,
|
||||
FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(
|
||||
stream,
|
||||
value,
|
||||
options,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Move(temporaryPath, fullPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(temporaryPath);
|
||||
}
|
||||
}
|
||||
|
||||
internal static async Task WriteBytesAsync(
|
||||
string path,
|
||||
ReadOnlyMemory<byte> bytes,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
string directory = Path.GetDirectoryName(fullPath)
|
||||
?? throw new InvalidOperationException("The file path has no parent directory.");
|
||||
Directory.CreateDirectory(directory);
|
||||
string temporaryPath = Path.Combine(
|
||||
directory,
|
||||
$".{Path.GetFileName(fullPath)}.{Guid.NewGuid():N}.tmp");
|
||||
try
|
||||
{
|
||||
await using (var stream = new FileStream(
|
||||
temporaryPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
16 * 1024,
|
||||
FileOptions.Asynchronous | FileOptions.WriteThrough))
|
||||
{
|
||||
await stream.WriteAsync(bytes, cancellationToken).ConfigureAwait(false);
|
||||
await stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
stream.Flush(flushToDisk: true);
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
File.Move(temporaryPath, fullPath, overwrite: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(temporaryPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
973
src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs
Normal file
973
src/AcDream.Launcher.Core/Updates/ClientVersionStore.cs
Normal file
|
|
@ -0,0 +1,973 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Launcher.Core.Integrity;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public sealed record InstalledFileRecord(
|
||||
string Path,
|
||||
string Sha256,
|
||||
long Size,
|
||||
int UnixMode);
|
||||
|
||||
public sealed record ClientVersionRecord(
|
||||
int SchemaVersion,
|
||||
string Version,
|
||||
string Rid,
|
||||
string ArchiveSha256,
|
||||
long ArchiveSize,
|
||||
IReadOnlyList<InstalledFileRecord> Files)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
public sealed record ClientActivationPointer(
|
||||
int SchemaVersion,
|
||||
string CurrentVersion,
|
||||
string? PreviousVersion)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
public enum ClientVersionState
|
||||
{
|
||||
Missing,
|
||||
Verified,
|
||||
Invalid,
|
||||
}
|
||||
|
||||
public sealed record ClientVersionResolution(
|
||||
ClientVersionState State,
|
||||
string Status,
|
||||
LauncherVersion? Version,
|
||||
string? Directory,
|
||||
string? PreviousVersion,
|
||||
ClientVersionRecord? Record)
|
||||
{
|
||||
public bool IsVerified => State == ClientVersionState.Verified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strict installed-version and activation-pointer authority. LA9's DAT/pak
|
||||
/// record is intentionally not represented here.
|
||||
/// </summary>
|
||||
public sealed class ClientVersionStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
WriteIndented = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
MaxDepth = 32,
|
||||
};
|
||||
|
||||
private readonly object _gate = new();
|
||||
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
|
||||
private ClientVersionResolution _cached = new(
|
||||
ClientVersionState.Missing,
|
||||
"No versioned client is installed. Check for updates to install one.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
|
||||
public ClientVersionStore(
|
||||
ApplicationPathSet paths,
|
||||
Func<string, CancellationToken, Task<string>>? computeSha256 = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
AppDirectory = Path.Combine(Path.GetFullPath(paths.DataDirectory), "app");
|
||||
CurrentPointerPath = Path.Combine(AppDirectory, "current.json");
|
||||
PreviousPointerPath = Path.Combine(AppDirectory, "current.previous.json");
|
||||
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
|
||||
_computeSha256 = computeSha256
|
||||
?? ((path, token) => FileIntegrity.ComputeSha256HexAsync(path, token));
|
||||
}
|
||||
|
||||
public string AppDirectory { get; }
|
||||
|
||||
public string CurrentPointerPath { get; }
|
||||
|
||||
public string PreviousPointerPath { get; }
|
||||
|
||||
public UpdateSessionBarrier Barrier { get; }
|
||||
|
||||
public ClientVersionResolution CachedResolution
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return _cached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string GetVersionDirectory(LauncherVersion version) =>
|
||||
Path.Combine(AppDirectory, version.Value);
|
||||
|
||||
public static string GetMetadataPath(string versionDirectory) =>
|
||||
Path.Combine(Path.GetFullPath(versionDirectory), "install.json");
|
||||
|
||||
public async Task<ClientVersionResolution> LoadAndRecoverAsync(
|
||||
string rid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive();
|
||||
return await LoadAndRecoverUnderLeaseAsync(rid, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (LauncherUpdateException ex) when (ex.InnerException is IOException)
|
||||
{
|
||||
// Another launcher may legitimately hold a shared session lease.
|
||||
// Pointer publication is atomic and old versions are retained, so
|
||||
// a read-only verification remains safe; mutation/recovery waits
|
||||
// for the next startup without active sessions.
|
||||
return await LoadCurrentReadOnlyAsync(rid, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ClientVersionResolution> LoadCurrentReadOnlyAsync(
|
||||
string rid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireRid(rid);
|
||||
PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ClientVersionResolution resolution = current.Pointer is null
|
||||
? (!File.Exists(CurrentPointerPath)
|
||||
? new ClientVersionResolution(
|
||||
ClientVersionState.Missing,
|
||||
"No versioned client is installed. Check for updates to install one.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
: Invalid(current.Error ?? "The client activation pointer is invalid."))
|
||||
: await ResolvePointerAsync(current.Pointer, rid, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
SetCached(resolution);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
internal async Task<ClientVersionResolution> LoadAndRecoverUnderLeaseAsync(
|
||||
string rid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
RequireRid(rid);
|
||||
Directory.CreateDirectory(AppDirectory);
|
||||
CleanupOwnedResidue();
|
||||
|
||||
PointerRead current = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (current.Pointer is not null)
|
||||
{
|
||||
ClientVersionResolution resolution = await ResolvePointerAsync(
|
||||
current.Pointer,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
SetCached(resolution);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
PointerRead previous = await ReadPointerAsync(PreviousPointerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (previous.Pointer is not null)
|
||||
{
|
||||
ClientVersionResolution recovered = await ResolvePointerAsync(
|
||||
previous.Pointer,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (recovered.IsVerified)
|
||||
{
|
||||
await WritePointerFileAsync(
|
||||
CurrentPointerPath,
|
||||
previous.Pointer,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
recovered = recovered with
|
||||
{
|
||||
Status = "Recovered the last valid client activation pointer.",
|
||||
};
|
||||
SetCached(recovered);
|
||||
return recovered;
|
||||
}
|
||||
}
|
||||
|
||||
ClientVersionResolution missingOrInvalid =
|
||||
!File.Exists(CurrentPointerPath) && !File.Exists(PreviousPointerPath)
|
||||
? new ClientVersionResolution(
|
||||
ClientVersionState.Missing,
|
||||
"No versioned client is installed. Check for updates to install one.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null)
|
||||
: new ClientVersionResolution(
|
||||
ClientVersionState.Invalid,
|
||||
current.Error
|
||||
?? previous.Error
|
||||
?? "No valid client activation pointer could be recovered.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null);
|
||||
SetCached(missingOrInvalid);
|
||||
return missingOrInvalid;
|
||||
}
|
||||
|
||||
internal async Task<ClientVersionResolution> PromoteAndActivateUnderLeaseAsync(
|
||||
string stagingDirectory,
|
||||
LauncherVersion version,
|
||||
string rid,
|
||||
ReleaseArtifact artifact,
|
||||
IReadOnlyList<ExtractedFileRecord> extractedFiles,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(stagingDirectory);
|
||||
ArgumentNullException.ThrowIfNull(version);
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
ArgumentNullException.ThrowIfNull(extractedFiles);
|
||||
RequireRid(rid);
|
||||
|
||||
string staging = Path.GetFullPath(stagingDirectory);
|
||||
RequireOwnedStagingPath(staging);
|
||||
ValidateRequiredExecutables(extractedFiles, rid, launcherPayload: false);
|
||||
if (extractedFiles.Any(file => string.Equals(
|
||||
file.Path,
|
||||
"install.json",
|
||||
StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The client ZIP may not provide the launcher's install.json record.");
|
||||
}
|
||||
|
||||
var record = new ClientVersionRecord(
|
||||
ClientVersionRecord.CurrentSchemaVersion,
|
||||
version.Value,
|
||||
rid,
|
||||
artifact.Sha256.ToLowerInvariant(),
|
||||
artifact.Size,
|
||||
extractedFiles
|
||||
.Select(file => new InstalledFileRecord(
|
||||
file.Path,
|
||||
file.Sha256,
|
||||
file.Size,
|
||||
file.UnixMode))
|
||||
.OrderBy(file => file.Path, StringComparer.Ordinal)
|
||||
.ToArray());
|
||||
ValidateRecord(record, version, rid);
|
||||
await AtomicJsonFile.WriteAsync(
|
||||
GetMetadataPath(staging),
|
||||
record,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ClientVersionResolution staged = await VerifyVersionDirectoryAsync(
|
||||
staging,
|
||||
version,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!staged.IsVerified)
|
||||
{
|
||||
throw new LauncherUpdateException(staged.Status);
|
||||
}
|
||||
|
||||
ClientActivationPointer? oldPointer = (await ReadPointerAsync(
|
||||
CurrentPointerPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false)).Pointer;
|
||||
string target = GetVersionDirectory(version);
|
||||
if (Directory.Exists(target))
|
||||
{
|
||||
ClientVersionResolution existing = await VerifyVersionDirectoryAsync(
|
||||
target,
|
||||
version,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (existing.IsVerified
|
||||
&& existing.Record is not null
|
||||
&& string.Equals(
|
||||
existing.Record.ArchiveSha256,
|
||||
artifact.Sha256,
|
||||
StringComparison.OrdinalIgnoreCase)
|
||||
&& existing.Record.ArchiveSize == artifact.Size)
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(staging);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (oldPointer is not null
|
||||
&& string.Equals(
|
||||
oldPointer.CurrentVersion,
|
||||
version.Value,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The active client version is corrupt and cannot be replaced in place. "
|
||||
+ "Roll back before repairing it.");
|
||||
}
|
||||
|
||||
string quarantine = Path.Combine(
|
||||
AppDirectory,
|
||||
$".client-corrupt-{Guid.NewGuid():N}");
|
||||
Directory.Move(target, quarantine);
|
||||
try
|
||||
{
|
||||
Directory.Move(staging, target);
|
||||
}
|
||||
catch
|
||||
{
|
||||
Directory.Move(quarantine, target);
|
||||
throw;
|
||||
}
|
||||
|
||||
SafeZipExtractor.TryDeleteDirectory(quarantine);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Directory.Move(staging, target);
|
||||
}
|
||||
|
||||
string? previousVersion = oldPointer is null
|
||||
|| string.Equals(
|
||||
oldPointer.CurrentVersion,
|
||||
version.Value,
|
||||
StringComparison.Ordinal)
|
||||
? oldPointer?.PreviousVersion
|
||||
: oldPointer.CurrentVersion;
|
||||
var pointer = new ClientActivationPointer(
|
||||
ClientActivationPointer.CurrentSchemaVersion,
|
||||
version.Value,
|
||||
previousVersion);
|
||||
await SavePointerAsync(pointer, cancellationToken).ConfigureAwait(false);
|
||||
ClientVersionResolution resolution = await ResolvePointerAsync(
|
||||
pointer,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!resolution.IsVerified)
|
||||
{
|
||||
throw new LauncherUpdateException(resolution.Status);
|
||||
}
|
||||
|
||||
SetCached(resolution);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
public async Task<ClientVersionResolution> RollbackAsync(
|
||||
string rid,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
using UpdateSessionBarrier.ExclusiveLease lease = Barrier.AcquireExclusive();
|
||||
PointerRead read = await ReadPointerAsync(CurrentPointerPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ClientActivationPointer pointer = read.Pointer
|
||||
?? throw new LauncherUpdateException(
|
||||
read.Error ?? "There is no active client version to roll back.");
|
||||
if (string.IsNullOrEmpty(pointer.PreviousVersion))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"There is no previous client version available for rollback.");
|
||||
}
|
||||
|
||||
LauncherVersion previous = LauncherVersion.Parse(pointer.PreviousVersion);
|
||||
ClientVersionResolution verified = await VerifyVersionDirectoryAsync(
|
||||
GetVersionDirectory(previous),
|
||||
previous,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!verified.IsVerified)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The previous client version cannot be activated: {verified.Status}");
|
||||
}
|
||||
|
||||
var swapped = new ClientActivationPointer(
|
||||
ClientActivationPointer.CurrentSchemaVersion,
|
||||
previous.Value,
|
||||
pointer.CurrentVersion);
|
||||
await SavePointerAsync(swapped, cancellationToken).ConfigureAwait(false);
|
||||
ClientVersionResolution resolution = await ResolvePointerAsync(
|
||||
swapped,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
SetCached(resolution);
|
||||
return resolution;
|
||||
}
|
||||
|
||||
internal string CreateClientStagingDirectory(Guid transactionId)
|
||||
{
|
||||
Directory.CreateDirectory(AppDirectory);
|
||||
return Path.Combine(AppDirectory, $".client-staging-{transactionId:N}");
|
||||
}
|
||||
|
||||
internal static void ValidateRequiredExecutables(
|
||||
IReadOnlyList<ExtractedFileRecord> files,
|
||||
string rid,
|
||||
bool launcherPayload)
|
||||
{
|
||||
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
|
||||
string[] required = launcherPayload
|
||||
? ["acdream-launcher" + suffix]
|
||||
: ["AcDream.App" + suffix, "acdream-headless" + suffix];
|
||||
foreach (string path in required)
|
||||
{
|
||||
ExtractedFileRecord? file = files.SingleOrDefault(candidate =>
|
||||
string.Equals(candidate.Path, path, StringComparison.Ordinal));
|
||||
if (file is null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release ZIP is missing required root executable '{path}'.");
|
||||
}
|
||||
|
||||
if (rid.StartsWith("linux-", StringComparison.Ordinal)
|
||||
&& (file.UnixMode & (int)UnixFileMode.UserExecute) == 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The Linux release executable '{path}' lacks owner execute permission.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ClientVersionResolution> ResolvePointerAsync(
|
||||
ClientActivationPointer pointer,
|
||||
string rid,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? error = ValidatePointer(pointer);
|
||||
if (error is not null)
|
||||
{
|
||||
return Invalid(error);
|
||||
}
|
||||
|
||||
LauncherVersion version = LauncherVersion.Parse(pointer.CurrentVersion);
|
||||
ClientVersionResolution resolution = await VerifyVersionDirectoryAsync(
|
||||
GetVersionDirectory(version),
|
||||
version,
|
||||
rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return resolution.IsVerified
|
||||
? resolution with { PreviousVersion = pointer.PreviousVersion }
|
||||
: resolution;
|
||||
}
|
||||
|
||||
private async Task<ClientVersionResolution> VerifyVersionDirectoryAsync(
|
||||
string directory,
|
||||
LauncherVersion version,
|
||||
string rid,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!Directory.Exists(directory))
|
||||
{
|
||||
return Invalid($"Client version {version} directory is missing.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
RejectReparseTree(directory);
|
||||
ClientVersionRecord? record = await ReadStrictAsync<ClientVersionRecord>(
|
||||
GetMetadataPath(directory),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (record is null)
|
||||
{
|
||||
return Invalid($"Client version {version} install.json is missing.");
|
||||
}
|
||||
|
||||
string? contractError = ValidateRecord(record, version, rid);
|
||||
if (contractError is not null)
|
||||
{
|
||||
return Invalid(contractError);
|
||||
}
|
||||
|
||||
string[] actualFiles = Directory.EnumerateFiles(
|
||||
directory,
|
||||
"*",
|
||||
SearchOption.AllDirectories)
|
||||
.Select(path => NormalizeRelative(directory, path))
|
||||
.Where(path => !string.Equals(
|
||||
path,
|
||||
"install.json",
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] recordedFiles = record.Files
|
||||
.Select(file => file.Path)
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (!actualFiles.SequenceEqual(recordedFiles, StringComparer.Ordinal))
|
||||
{
|
||||
return Invalid(
|
||||
$"Client version {version} contains missing or unrecorded files.");
|
||||
}
|
||||
|
||||
foreach (InstalledFileRecord file in record.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string path = ResolveContained(directory, file.Path);
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Length != file.Size)
|
||||
{
|
||||
return Invalid(
|
||||
$"Client version {version} file '{file.Path}' size is corrupt.");
|
||||
}
|
||||
|
||||
string sha256 = await _computeSha256(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!string.Equals(
|
||||
sha256,
|
||||
file.Sha256,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return Invalid(
|
||||
$"Client version {version} file '{file.Path}' SHA-256 is corrupt.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux()
|
||||
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
|
||||
{
|
||||
return Invalid(
|
||||
$"Client version {version} file '{file.Path}' mode is corrupt.");
|
||||
}
|
||||
}
|
||||
|
||||
return new ClientVersionResolution(
|
||||
ClientVersionState.Verified,
|
||||
$"Client version {version} verified.",
|
||||
version,
|
||||
directory,
|
||||
null,
|
||||
record);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException
|
||||
or NotSupportedException
|
||||
or FormatException
|
||||
or LauncherUpdateException)
|
||||
{
|
||||
return Invalid(
|
||||
$"Client version {version} could not be verified: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SavePointerAsync(
|
||||
ClientActivationPointer pointer,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? error = ValidatePointer(pointer);
|
||||
if (error is not null)
|
||||
{
|
||||
throw new LauncherUpdateException(error);
|
||||
}
|
||||
|
||||
if (File.Exists(CurrentPointerPath))
|
||||
{
|
||||
byte[] previous = await File.ReadAllBytesAsync(
|
||||
CurrentPointerPath,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
PointerRead validPrevious = ParsePointer(previous);
|
||||
if (validPrevious.Pointer is not null)
|
||||
{
|
||||
await AtomicJsonFile.WriteBytesAsync(
|
||||
PreviousPointerPath,
|
||||
previous,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
await WritePointerFileAsync(CurrentPointerPath, pointer, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static Task WritePointerFileAsync(
|
||||
string path,
|
||||
ClientActivationPointer pointer,
|
||||
CancellationToken cancellationToken) =>
|
||||
AtomicJsonFile.WriteAsync(path, pointer, SerializerOptions, cancellationToken);
|
||||
|
||||
private static async Task<PointerRead> ReadPointerAsync(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return new PointerRead(null, null);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return ParsePointer(bytes);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
return new PointerRead(null, $"Client pointer could not be read: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static PointerRead ParsePointer(ReadOnlyMemory<byte> bytes)
|
||||
{
|
||||
try
|
||||
{
|
||||
ClientActivationPointer? pointer = ParseStrict<ClientActivationPointer>(bytes.Span);
|
||||
string? error = pointer is null
|
||||
? "Client pointer is empty."
|
||||
: ValidatePointer(pointer);
|
||||
return error is null
|
||||
? new PointerRead(pointer, null)
|
||||
: new PointerRead(null, error);
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException
|
||||
or LauncherUpdateException
|
||||
or FormatException)
|
||||
{
|
||||
return new PointerRead(null, $"Client pointer is invalid: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ValidatePointer(ClientActivationPointer pointer)
|
||||
{
|
||||
if (pointer.SchemaVersion != ClientActivationPointer.CurrentSchemaVersion)
|
||||
{
|
||||
return $"Client pointer schema version {pointer.SchemaVersion} is not supported.";
|
||||
}
|
||||
|
||||
if (!LauncherVersion.TryParse(pointer.CurrentVersion, out _))
|
||||
{
|
||||
return "Client pointer currentVersion is invalid.";
|
||||
}
|
||||
|
||||
if (pointer.PreviousVersion is not null
|
||||
&& (!LauncherVersion.TryParse(pointer.PreviousVersion, out _)
|
||||
|| string.Equals(
|
||||
pointer.PreviousVersion,
|
||||
pointer.CurrentVersion,
|
||||
StringComparison.Ordinal)))
|
||||
{
|
||||
return "Client pointer previousVersion is invalid.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateRecord(
|
||||
ClientVersionRecord record,
|
||||
LauncherVersion version,
|
||||
string rid)
|
||||
{
|
||||
if (record.SchemaVersion != ClientVersionRecord.CurrentSchemaVersion)
|
||||
{
|
||||
return $"Client install schema version {record.SchemaVersion} is not supported.";
|
||||
}
|
||||
|
||||
if (!string.Equals(record.Version, version.Value, StringComparison.Ordinal)
|
||||
|| !LauncherVersion.TryParse(record.Version, out _))
|
||||
{
|
||||
return "Client install version does not match its directory.";
|
||||
}
|
||||
|
||||
if (!string.Equals(record.Rid, rid, StringComparison.Ordinal)
|
||||
|| !LauncherRuntimeIdentity.IsValidRid(record.Rid))
|
||||
{
|
||||
return $"Client install RID does not match '{rid}'.";
|
||||
}
|
||||
|
||||
if (!ReleaseManifestClient.IsSha256(record.ArchiveSha256)
|
||||
|| record.ArchiveSize <= 0
|
||||
|| record.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
|
||||
{
|
||||
return "Client install archive metadata is invalid.";
|
||||
}
|
||||
|
||||
if (record.Files is null || record.Files.Count == 0)
|
||||
{
|
||||
return "Client install file list is empty.";
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? prior = null;
|
||||
foreach (InstalledFileRecord file in record.Files)
|
||||
{
|
||||
if (!IsNormalizedRelative(file.Path)
|
||||
|| !paths.Add(file.Path)
|
||||
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|
||||
|| file.Size < 0
|
||||
|| file.UnixMode is < 0 or > 0x1FF
|
||||
|| (prior is not null
|
||||
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
|
||||
{
|
||||
return "Client install file metadata is invalid, duplicated, or unsorted.";
|
||||
}
|
||||
|
||||
prior = file.Path;
|
||||
}
|
||||
|
||||
string suffix = rid.StartsWith("win-", StringComparison.Ordinal) ? ".exe" : string.Empty;
|
||||
foreach (string required in new[]
|
||||
{
|
||||
"AcDream.App" + suffix,
|
||||
"acdream-headless" + suffix,
|
||||
})
|
||||
{
|
||||
if (!paths.Contains(required))
|
||||
{
|
||||
return $"Client install is missing '{required}'.";
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<T?> ReadStrictAsync<T>(
|
||||
string path,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return default;
|
||||
}
|
||||
|
||||
byte[] bytes = await File.ReadAllBytesAsync(path, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return ParseStrict<T>(bytes);
|
||||
}
|
||||
|
||||
internal static T? ParseStrict<T>(
|
||||
ReadOnlySpan<byte> bytes,
|
||||
JsonSerializerOptions? serializerOptions = null)
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
bytes.ToArray(),
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
MaxDepth = 32,
|
||||
});
|
||||
RejectDuplicateProperties(document.RootElement, "$" );
|
||||
return document.RootElement.Deserialize<T>(
|
||||
serializerOptions ?? SerializerOptions);
|
||||
}
|
||||
|
||||
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (!names.Add(property.Name))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Duplicate JSON property '{path}.{property.Name}' is not allowed.");
|
||||
}
|
||||
|
||||
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int index = 0;
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
RejectDuplicateProperties(item, $"{path}[{index++}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOwnedResidue()
|
||||
{
|
||||
foreach (string path in Directory.EnumerateDirectories(
|
||||
AppDirectory,
|
||||
".client-staging-*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string suffix = Path.GetFileName(path)[".client-staging-".Length..];
|
||||
if (Guid.TryParseExact(suffix, "N", out _))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(path);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (string path in Directory.EnumerateFiles(
|
||||
AppDirectory,
|
||||
".current*.tmp",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string fileName = Path.GetFileName(path);
|
||||
string[] parts = fileName.Split('.');
|
||||
if (parts.Length >= 4
|
||||
&& string.Equals(parts[^1], "tmp", StringComparison.Ordinal)
|
||||
&& Guid.TryParseExact(parts[^2], "N", out _))
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 _))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The client extraction path is not an owned LA10 staging directory.");
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RejectReparseTree(string root)
|
||||
{
|
||||
if ((File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException("The client version directory is a reparse point.");
|
||||
}
|
||||
|
||||
var pending = new Stack<string>();
|
||||
pending.Push(root);
|
||||
while (pending.TryPop(out string? directory))
|
||||
{
|
||||
foreach (string path in Directory.EnumerateFileSystemEntries(
|
||||
directory,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
FileAttributes attributes = File.GetAttributes(path);
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Client install path '{NormalizeRelative(root, path)}' is a reparse point.");
|
||||
}
|
||||
|
||||
if ((attributes & FileAttributes.Directory) != 0)
|
||||
{
|
||||
pending.Push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeRelative(string root, string path) =>
|
||||
Path.GetRelativePath(root, path).Replace('\\', '/');
|
||||
|
||||
internal static bool IsNormalizedRelative(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)
|
||||
|| path.Length > 512
|
||||
|| path.IndexOf('\0') >= 0
|
||||
|| path.Contains('\\', StringComparison.Ordinal)
|
||||
|| path.Contains(':', StringComparison.Ordinal)
|
||||
|| path.StartsWith("/", StringComparison.Ordinal)
|
||||
|| Path.IsPathRooted(path))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string[] parts = path.Split('/');
|
||||
return parts.All(part =>
|
||||
part.Length > 0
|
||||
&& part is not ("." or "..")
|
||||
&& !part.EndsWith(' ')
|
||||
&& !part.EndsWith('.')
|
||||
&& !part.Any(character =>
|
||||
char.IsControl(character)
|
||||
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||
&& !IsWindowsDeviceName(part));
|
||||
}
|
||||
|
||||
internal static string ResolveContained(string root, string relative)
|
||||
{
|
||||
if (!IsNormalizedRelative(relative))
|
||||
{
|
||||
throw new LauncherUpdateException($"Unsafe relative path '{relative}'.");
|
||||
}
|
||||
|
||||
string fullRoot = Path.GetFullPath(root);
|
||||
string path = Path.GetFullPath(
|
||||
Path.Combine(fullRoot, relative.Replace('/', Path.DirectorySeparatorChar)));
|
||||
string prefix = Path.EndsInDirectorySeparator(fullRoot)
|
||||
? fullRoot
|
||||
: fullRoot + Path.DirectorySeparatorChar;
|
||||
if (!path.StartsWith(
|
||||
prefix,
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException($"Path '{relative}' escaped its root.");
|
||||
}
|
||||
|
||||
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)),
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private static void RequireRid(string rid)
|
||||
{
|
||||
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||
{
|
||||
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCached(ClientVersionResolution resolution)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_cached = resolution;
|
||||
}
|
||||
}
|
||||
|
||||
private static ClientVersionResolution Invalid(string status) =>
|
||||
new(ClientVersionState.Invalid, status, null, null, null, null);
|
||||
|
||||
private sealed record PointerRead(ClientActivationPointer? Pointer, string? Error);
|
||||
}
|
||||
33
src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs
Normal file
33
src/AcDream.Launcher.Core/Updates/LauncherRuntimeIdentity.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public static class LauncherRuntimeIdentity
|
||||
{
|
||||
public static string DetectRid()
|
||||
{
|
||||
string os = OperatingSystem.IsWindows()
|
||||
? "win"
|
||||
: OperatingSystem.IsLinux()
|
||||
? "linux"
|
||||
: throw new PlatformNotSupportedException(
|
||||
"The launcher updater supports Windows and Linux only.");
|
||||
string architecture = RuntimeInformation.ProcessArchitecture switch
|
||||
{
|
||||
Architecture.X64 => "x64",
|
||||
Architecture.Arm64 => "arm64",
|
||||
_ => throw new PlatformNotSupportedException(
|
||||
$"The launcher updater does not support {RuntimeInformation.ProcessArchitecture}."),
|
||||
};
|
||||
return $"{os}-{architecture}";
|
||||
}
|
||||
|
||||
internal static bool IsValidRid(string? rid) =>
|
||||
!string.IsNullOrEmpty(rid)
|
||||
&& rid.Length <= 64
|
||||
&& rid[0] is >= 'a' and <= 'z'
|
||||
&& rid.All(character =>
|
||||
character is >= 'a' and <= 'z'
|
||||
or >= '0' and <= '9'
|
||||
or '-');
|
||||
}
|
||||
299
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
Normal file
299
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateBootstrap.cs
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
using System.Diagnostics;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public sealed record SelfUpdateStartupResult(
|
||||
bool ShouldExit,
|
||||
int ExitCode,
|
||||
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.
|
||||
/// </summary>
|
||||
public static class LauncherSelfUpdateBootstrap
|
||||
{
|
||||
public const string HelperArgument = "--acdream-self-update-helper-v1";
|
||||
public const string ConfirmArgument = "--acdream-self-update-confirm-v1";
|
||||
private static readonly TimeSpan ConfirmationTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
public static async Task<SelfUpdateStartupResult> HandleAsync(
|
||||
string[] args,
|
||||
LauncherSelfUpdateManager manager,
|
||||
string launcherBaseDirectory,
|
||||
string currentExecutablePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(args);
|
||||
ArgumentNullException.ThrowIfNull(manager);
|
||||
string baseDirectory = Path.TrimEndingDirectorySeparator(
|
||||
Path.GetFullPath(launcherBaseDirectory));
|
||||
string executable = Path.GetFullPath(currentExecutablePath);
|
||||
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], HelperArgument, StringComparison.Ordinal))
|
||||
{
|
||||
if (args.Length != 4
|
||||
|| !int.TryParse(
|
||||
args[1],
|
||||
System.Globalization.NumberStyles.None,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int parentPid)
|
||||
|| parentPid <= 0)
|
||||
{
|
||||
return new SelfUpdateStartupResult(true, 64, []);
|
||||
}
|
||||
|
||||
int exitCode = await RunHelperAsync(
|
||||
manager,
|
||||
parentPid,
|
||||
args[2],
|
||||
args[3],
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
return new SelfUpdateStartupResult(true, exitCode, []);
|
||||
}
|
||||
|
||||
if (args.Length > 0
|
||||
&& string.Equals(args[0], ConfirmArgument, StringComparison.Ordinal))
|
||||
{
|
||||
if (args.Length != 2)
|
||||
{
|
||||
return new SelfUpdateStartupResult(true, 64, []);
|
||||
}
|
||||
|
||||
await manager.ConfirmAsync(
|
||||
args[1],
|
||||
baseDirectory,
|
||||
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,
|
||||
baseDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
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))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Self-update can start only from the published acdream-launcher executable.");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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, []);
|
||||
}
|
||||
|
||||
private static async Task<int> RunHelperAsync(
|
||||
LauncherSelfUpdateManager manager,
|
||||
int parentPid,
|
||||
string targetDirectory,
|
||||
string transactionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
SelfUpdatePlan plan = await manager.LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("The helper found no pending self-update.");
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The helper transaction does not match the pending self-update.");
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
string launcherPath = ClientVersionStore.ResolveContained(
|
||||
targetDirectory,
|
||||
"acdream-launcher" + suffix);
|
||||
var startInfo = new ProcessStartInfo(launcherPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||
};
|
||||
startInfo.ArgumentList.Add(ConfirmArgument);
|
||||
startInfo.ArgumentList.Add(transactionId);
|
||||
|
||||
Process? replacement = null;
|
||||
UpdateSessionBarrier.ExclusiveLease? updateLease = null;
|
||||
bool appliedByThisHelper = false;
|
||||
try
|
||||
{
|
||||
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;
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (replacement is { HasExited: false })
|
||||
{
|
||||
replacement.Kill(entireProcessTree: true);
|
||||
await replacement.WaitForExitAsync(CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
// Do not start an executable from an ambiguous half-applied
|
||||
// state. A subsequent startup replays the durable journal.
|
||||
return 75;
|
||||
}
|
||||
|
||||
var restored = new ProcessStartInfo(launcherPath)
|
||||
{
|
||||
UseShellExecute = false,
|
||||
WorkingDirectory = Path.GetFullPath(targetDirectory),
|
||||
};
|
||||
_ = Process.Start(restored);
|
||||
return 74;
|
||||
}
|
||||
finally
|
||||
{
|
||||
replacement?.Dispose();
|
||||
updateLease?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task WaitForParentExitAsync(
|
||||
int parentPid,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using Process parent = Process.GetProcessById(parentPid);
|
||||
if (parent.Id == Environment.ProcessId)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update helper cannot wait on itself.");
|
||||
}
|
||||
|
||||
await parent.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// The parent exited before the helper opened it.
|
||||
}
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) =>
|
||||
string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
}
|
||||
786
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
Normal file
786
src/AcDream.Launcher.Core/Updates/LauncherSelfUpdateManager.cs
Normal file
|
|
@ -0,0 +1,786 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using AcDream.Platform;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public enum SelfUpdatePlanState
|
||||
{
|
||||
Staged,
|
||||
Applying,
|
||||
AwaitingConfirmation,
|
||||
}
|
||||
|
||||
public sealed record SelfUpdateApplyEntry(string Path, bool HadOriginal);
|
||||
|
||||
public sealed record SelfUpdatePlan(
|
||||
int SchemaVersion,
|
||||
string TransactionId,
|
||||
SelfUpdatePlanState State,
|
||||
string Version,
|
||||
string Rid,
|
||||
string TargetDirectory,
|
||||
string ArchiveSha256,
|
||||
long ArchiveSize,
|
||||
IReadOnlyList<InstalledFileRecord> Files,
|
||||
IReadOnlyList<SelfUpdateApplyEntry>? Apply)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
}
|
||||
|
||||
public sealed record SelfUpdateStageResult(
|
||||
LauncherVersion Version,
|
||||
string PendingPlanPath,
|
||||
string Status);
|
||||
|
||||
/// <summary>
|
||||
/// Durable self-update transaction owner. It stages a verified launcher ZIP;
|
||||
/// a separately copied helper performs move-only replacement after the parent
|
||||
/// exits and can replay rollback after a crash at any file boundary.
|
||||
/// </summary>
|
||||
public sealed class LauncherSelfUpdateManager
|
||||
{
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
WriteIndented = true,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
MaxDepth = 32,
|
||||
Converters = { new JsonStringEnumConverter<SelfUpdatePlanState>(
|
||||
JsonNamingPolicy.CamelCase,
|
||||
allowIntegerValues: false) },
|
||||
};
|
||||
|
||||
private readonly VerifiedArtifactDownloader _downloader;
|
||||
private readonly SafeZipExtractor _extractor;
|
||||
|
||||
public LauncherSelfUpdateManager(
|
||||
ApplicationPathSet paths,
|
||||
HttpClient httpClient,
|
||||
SafeZipExtractor? extractor = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(paths);
|
||||
RootDirectory = Path.Combine(
|
||||
Path.GetFullPath(paths.DataDirectory),
|
||||
"launcher-update");
|
||||
TransactionsDirectory = Path.Combine(RootDirectory, "transactions");
|
||||
PendingPlanPath = Path.Combine(RootDirectory, "pending.json");
|
||||
Barrier = new UpdateSessionBarrier(paths.DataDirectory);
|
||||
_downloader = new VerifiedArtifactDownloader(
|
||||
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
|
||||
_extractor = extractor ?? new SafeZipExtractor();
|
||||
}
|
||||
|
||||
public string RootDirectory { get; }
|
||||
|
||||
public string TransactionsDirectory { get; }
|
||||
|
||||
public string PendingPlanPath { get; }
|
||||
|
||||
public UpdateSessionBarrier Barrier { get; }
|
||||
|
||||
public async Task<SelfUpdateStageResult> StageAsync(
|
||||
ReleaseManifest manifest,
|
||||
string rid,
|
||||
string targetDirectory,
|
||||
IProgress<ArtifactDownloadProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(manifest);
|
||||
ReleaseArtifact artifact = manifest.RequireLauncher(rid);
|
||||
return await StageAsync(
|
||||
manifest.Version,
|
||||
rid,
|
||||
artifact,
|
||||
targetDirectory,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
internal async Task<SelfUpdateStageResult> StageAsync(
|
||||
LauncherVersion version,
|
||||
string rid,
|
||||
ReleaseArtifact artifact,
|
||||
string targetDirectory,
|
||||
IProgress<ArtifactDownloadProgress>? progress,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(version);
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||
{
|
||||
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||
}
|
||||
|
||||
string target = NormalizeTargetDirectory(targetDirectory);
|
||||
Directory.CreateDirectory(RootDirectory);
|
||||
Directory.CreateDirectory(TransactionsDirectory);
|
||||
SelfUpdatePlan? existing = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (existing is not null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Launcher self-update {existing.Version} is already {existing.State}. "
|
||||
+ "Restart the launcher to finish it before staging another.");
|
||||
}
|
||||
|
||||
string transactionId = Guid.NewGuid().ToString("N");
|
||||
string transactionDirectory = GetTransactionDirectory(transactionId);
|
||||
string payloadDirectory = GetPayloadDirectory(transactionId);
|
||||
string archivePath = Path.Combine(transactionDirectory, "launcher.zip");
|
||||
Directory.CreateDirectory(transactionDirectory);
|
||||
try
|
||||
{
|
||||
_ = await _downloader.DownloadAsync(
|
||||
artifact,
|
||||
archivePath,
|
||||
progress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IReadOnlyList<ExtractedFileRecord> extracted = await _extractor.ExtractAsync(
|
||||
archivePath,
|
||||
payloadDirectory,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
ClientVersionStore.ValidateRequiredExecutables(
|
||||
extracted,
|
||||
rid,
|
||||
launcherPayload: true);
|
||||
|
||||
var plan = new SelfUpdatePlan(
|
||||
SelfUpdatePlan.CurrentSchemaVersion,
|
||||
transactionId,
|
||||
SelfUpdatePlanState.Staged,
|
||||
version.Value,
|
||||
rid,
|
||||
target,
|
||||
artifact.Sha256.ToLowerInvariant(),
|
||||
artifact.Size,
|
||||
extracted.Select(file => new InstalledFileRecord(
|
||||
file.Path,
|
||||
file.Sha256,
|
||||
file.Size,
|
||||
file.UnixMode))
|
||||
.OrderBy(file => file.Path, StringComparer.Ordinal)
|
||||
.ToArray(),
|
||||
null);
|
||||
ValidatePlan(plan, target);
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
VerifiedArtifactDownloader.TryDelete(archivePath);
|
||||
return new SelfUpdateStageResult(
|
||||
version,
|
||||
PendingPlanPath,
|
||||
$"Launcher {version} is staged and will be applied on next start.");
|
||||
}
|
||||
catch
|
||||
{
|
||||
if (!File.Exists(PendingPlanPath))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(transactionDirectory);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan?> LoadPendingAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (!File.Exists(PendingPlanPath))
|
||||
{
|
||||
CleanupOwnedResidue(keepTransactionId: null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
byte[] bytes = await File.ReadAllBytesAsync(PendingPlanPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
SelfUpdatePlan? plan = ClientVersionStore.ParseStrict<SelfUpdatePlan>(
|
||||
bytes,
|
||||
SerializerOptions);
|
||||
if (plan is null)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update plan is empty.");
|
||||
}
|
||||
|
||||
ValidatePlan(plan, plan.TargetDirectory);
|
||||
CleanupOwnedResidue(plan.TransactionId);
|
||||
return plan;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or JsonException
|
||||
or FormatException
|
||||
or NotSupportedException)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The pending launcher self-update is invalid: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> ApplyPendingAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no staged launcher self-update.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
return plan;
|
||||
}
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.Applying)
|
||||
{
|
||||
plan = await RollbackApplyingAsync(plan, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
var apply = new List<SelfUpdateApplyEntry>(plan.Files.Count);
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
string targetPath = ClientVersionStore.ResolveContained(
|
||||
expectedTarget,
|
||||
file.Path);
|
||||
if (Directory.Exists(targetPath))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update target '{file.Path}' is unexpectedly a directory.");
|
||||
}
|
||||
|
||||
apply.Add(new SelfUpdateApplyEntry(file.Path, File.Exists(targetPath)));
|
||||
}
|
||||
|
||||
plan = plan with
|
||||
{
|
||||
State = SelfUpdatePlanState.Applying,
|
||||
Apply = apply,
|
||||
};
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||
string backup = GetBackupDirectory(plan.TransactionId);
|
||||
try
|
||||
{
|
||||
foreach (SelfUpdateApplyEntry entry in plan.Apply)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
|
||||
string targetPath = ClientVersionStore.ResolveContained(expectedTarget, entry.Path);
|
||||
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
|
||||
EnsureSafeParent(expectedTarget, targetPath);
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!);
|
||||
if (entry.HadOriginal)
|
||||
{
|
||||
File.Move(targetPath, backupPath);
|
||||
}
|
||||
|
||||
File.Move(stagedPath, targetPath);
|
||||
InstalledFileRecord file = plan.Files.Single(candidate =>
|
||||
string.Equals(candidate.Path, entry.Path, StringComparison.Ordinal));
|
||||
if (OperatingSystem.IsLinux() && file.UnixMode != 0)
|
||||
{
|
||||
File.SetUnixFileMode(targetPath, (UnixFileMode)file.UnixMode);
|
||||
}
|
||||
}
|
||||
|
||||
plan = plan with { State = SelfUpdatePlanState.AwaitingConfirmation };
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
return plan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
await RollbackApplyingAsync(plan, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> RecoverApplyingAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no pending self-update.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
return plan.State == SelfUpdatePlanState.Applying
|
||||
? await RollbackApplyingAsync(plan, cancellationToken).ConfigureAwait(false)
|
||||
: plan;
|
||||
}
|
||||
|
||||
public async Task ConfirmAsync(
|
||||
string transactionId,
|
||||
string expectedTargetDirectory,
|
||||
string currentExecutablePath,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no self-update to confirm.");
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The running launcher does not match the pending confirmation plan.");
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
string expectedExecutable = ClientVersionStore.ResolveContained(
|
||||
expectedTarget,
|
||||
"acdream-launcher" + suffix);
|
||||
if (!PathsEqual(expectedExecutable, currentExecutablePath))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Only the newly installed launcher executable may confirm self-update.");
|
||||
}
|
||||
|
||||
await VerifyAppliedTargetsAsync(plan, expectedTarget, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
string confirmationPath = GetConfirmationPath(transactionId);
|
||||
await AtomicJsonFile.WriteBytesAsync(
|
||||
confirmationPath,
|
||||
"confirmed"u8.ToArray(),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public bool IsConfirmed(string transactionId) =>
|
||||
File.Exists(GetConfirmationPath(transactionId));
|
||||
|
||||
public async Task CompleteConfirmedAsync(
|
||||
string transactionId,
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no self-update to complete.");
|
||||
ValidatePlan(plan, NormalizeTargetDirectory(expectedTargetDirectory));
|
||||
if (!string.Equals(plan.TransactionId, transactionId, StringComparison.Ordinal)
|
||||
|| plan.State != SelfUpdatePlanState.AwaitingConfirmation
|
||||
|| !IsConfirmed(transactionId))
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update is not confirmed.");
|
||||
}
|
||||
|
||||
File.Delete(PendingPlanPath);
|
||||
SafeZipExtractor.TryDeleteDirectory(GetTransactionDirectory(transactionId));
|
||||
}
|
||||
|
||||
public async Task<SelfUpdatePlan> RollbackAwaitingConfirmationAsync(
|
||||
string expectedTargetDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
string expectedTarget = NormalizeTargetDirectory(expectedTargetDirectory);
|
||||
SelfUpdatePlan plan = await LoadPendingAsync(cancellationToken)
|
||||
.ConfigureAwait(false)
|
||||
?? throw new LauncherUpdateException("There is no self-update to roll back.");
|
||||
ValidatePlan(plan, expectedTarget);
|
||||
if (plan.State != SelfUpdatePlanState.AwaitingConfirmation)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The pending self-update is not awaiting confirmation.");
|
||||
}
|
||||
|
||||
plan = plan with { State = SelfUpdatePlanState.Applying };
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
return await RollbackApplyingAsync(plan, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public string GetTransactionDirectory(string transactionId)
|
||||
{
|
||||
RequireTransactionId(transactionId);
|
||||
return Path.Combine(TransactionsDirectory, transactionId);
|
||||
}
|
||||
|
||||
public string GetPayloadDirectory(string transactionId) =>
|
||||
Path.Combine(GetTransactionDirectory(transactionId), "payload");
|
||||
|
||||
public string GetBackupDirectory(string transactionId) =>
|
||||
Path.Combine(GetTransactionDirectory(transactionId), "backup");
|
||||
|
||||
public string GetConfirmationPath(string transactionId) =>
|
||||
Path.Combine(GetTransactionDirectory(transactionId), "confirmed");
|
||||
|
||||
public string GetHelperPath(string transactionId)
|
||||
{
|
||||
string suffix = OperatingSystem.IsWindows() ? ".exe" : string.Empty;
|
||||
return Path.Combine(
|
||||
GetTransactionDirectory(transactionId),
|
||||
"acdream-self-update-helper" + suffix);
|
||||
}
|
||||
|
||||
private async Task<SelfUpdatePlan> RollbackApplyingAsync(
|
||||
SelfUpdatePlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (plan.State != SelfUpdatePlanState.Applying || plan.Apply is null)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update rollback journal is missing.");
|
||||
}
|
||||
|
||||
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||
string backup = GetBackupDirectory(plan.TransactionId);
|
||||
foreach (SelfUpdateApplyEntry entry in plan.Apply.Reverse())
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string stagedPath = ClientVersionStore.ResolveContained(payload, entry.Path);
|
||||
string targetPath = ClientVersionStore.ResolveContained(
|
||||
plan.TargetDirectory,
|
||||
entry.Path);
|
||||
string backupPath = ClientVersionStore.ResolveContained(backup, entry.Path);
|
||||
if (!File.Exists(stagedPath) && File.Exists(targetPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(stagedPath)!);
|
||||
File.Move(targetPath, stagedPath);
|
||||
}
|
||||
|
||||
if (entry.HadOriginal && File.Exists(backupPath))
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!);
|
||||
File.Move(backupPath, targetPath);
|
||||
}
|
||||
else if (!entry.HadOriginal && File.Exists(targetPath))
|
||||
{
|
||||
File.Delete(targetPath);
|
||||
}
|
||||
}
|
||||
|
||||
SafeZipExtractor.TryDeleteDirectory(backup);
|
||||
plan = plan with
|
||||
{
|
||||
State = SelfUpdatePlanState.Staged,
|
||||
Apply = null,
|
||||
};
|
||||
await WritePlanAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
await VerifyPayloadAsync(plan, cancellationToken).ConfigureAwait(false);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private async Task VerifyPayloadAsync(
|
||||
SelfUpdatePlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string payload = GetPayloadDirectory(plan.TransactionId);
|
||||
if (!Directory.Exists(payload))
|
||||
{
|
||||
throw new LauncherUpdateException("The staged launcher payload is missing.");
|
||||
}
|
||||
|
||||
ClientVersionStore.RejectReparseTree(payload);
|
||||
string[] actual = Directory.EnumerateFiles(
|
||||
payload,
|
||||
"*",
|
||||
SearchOption.AllDirectories)
|
||||
.Select(path => Path.GetRelativePath(payload, path).Replace('\\', '/'))
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
string[] expected = plan.Files
|
||||
.Select(file => file.Path)
|
||||
.OrderBy(path => path, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (!actual.SequenceEqual(expected, StringComparer.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The staged launcher contains missing or unrecorded files.");
|
||||
}
|
||||
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string path = ClientVersionStore.ResolveContained(payload, file.Path);
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists || info.Length != file.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Staged launcher file '{file.Path}' size is corrupt.");
|
||||
}
|
||||
|
||||
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
|
||||
path,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Staged launcher file '{file.Path}' SHA-256 is corrupt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task VerifyAppliedTargetsAsync(
|
||||
SelfUpdatePlan plan,
|
||||
string targetDirectory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string path = ClientVersionStore.ResolveContained(targetDirectory, file.Path);
|
||||
EnsureSafeParent(targetDirectory, path);
|
||||
var info = new FileInfo(path);
|
||||
if (!info.Exists
|
||||
|| (info.Attributes & FileAttributes.ReparsePoint) != 0
|
||||
|| info.Length != file.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Applied launcher file '{file.Path}' is missing, linked, or corrupt.");
|
||||
}
|
||||
|
||||
string sha256 = await Integrity.FileIntegrity.ComputeSha256HexAsync(
|
||||
path,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!string.Equals(sha256, file.Sha256, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Applied launcher file '{file.Path}' SHA-256 is corrupt.");
|
||||
}
|
||||
|
||||
if (OperatingSystem.IsLinux()
|
||||
&& ((int)File.GetUnixFileMode(path) & 0x1FF) != file.UnixMode)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Applied launcher file '{file.Path}' mode is corrupt.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task WritePlanAsync(
|
||||
SelfUpdatePlan plan,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ValidatePlan(plan, plan.TargetDirectory);
|
||||
await AtomicJsonFile.WriteAsync(
|
||||
PendingPlanPath,
|
||||
plan,
|
||||
SerializerOptions,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private void ValidatePlan(SelfUpdatePlan plan, string expectedTargetDirectory)
|
||||
{
|
||||
if (plan.SchemaVersion != SelfUpdatePlan.CurrentSchemaVersion)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update schema version {plan.SchemaVersion} is not supported.");
|
||||
}
|
||||
|
||||
RequireTransactionId(plan.TransactionId);
|
||||
if (!LauncherVersion.TryParse(plan.Version, out _)
|
||||
|| !LauncherRuntimeIdentity.IsValidRid(plan.Rid)
|
||||
|| !ReleaseManifestClient.IsSha256(plan.ArchiveSha256)
|
||||
|| plan.ArchiveSize <= 0
|
||||
|| plan.ArchiveSize > ReleaseManifestClient.MaximumArtifactBytes)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update plan metadata is invalid.");
|
||||
}
|
||||
|
||||
string target = NormalizeTargetDirectory(plan.TargetDirectory);
|
||||
if (!PathsEqual(target, expectedTargetDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update target does not match the running launcher directory.");
|
||||
}
|
||||
|
||||
if (plan.Files is null || plan.Files.Count == 0)
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update file list is empty.");
|
||||
}
|
||||
|
||||
var paths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
string? prior = null;
|
||||
foreach (InstalledFileRecord file in plan.Files)
|
||||
{
|
||||
if (!ClientVersionStore.IsNormalizedRelative(file.Path)
|
||||
|| !paths.Add(file.Path)
|
||||
|| !ReleaseManifestClient.IsSha256(file.Sha256)
|
||||
|| file.Size < 0
|
||||
|| file.UnixMode is < 0 or > 0x1FF
|
||||
|| (prior is not null
|
||||
&& string.Compare(prior, file.Path, StringComparison.Ordinal) >= 0))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update file list is invalid, duplicated, or unsorted.");
|
||||
}
|
||||
|
||||
prior = file.Path;
|
||||
}
|
||||
|
||||
string suffix = plan.Rid.StartsWith("win-", StringComparison.Ordinal)
|
||||
? ".exe"
|
||||
: string.Empty;
|
||||
if (!paths.Contains("acdream-launcher" + suffix))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update plan lacks the launcher root executable.");
|
||||
}
|
||||
|
||||
if (plan.State == SelfUpdatePlanState.Staged && plan.Apply is not null
|
||||
|| plan.State != SelfUpdatePlanState.Staged && plan.Apply is null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update apply journal does not match its state.");
|
||||
}
|
||||
|
||||
if (plan.Apply is not null)
|
||||
{
|
||||
if (plan.Apply.Count != plan.Files.Count
|
||||
|| !plan.Apply.Select(entry => entry.Path)
|
||||
.SequenceEqual(plan.Files.Select(file => file.Path), StringComparer.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update apply journal does not match the file list.");
|
||||
}
|
||||
}
|
||||
|
||||
string transactionDirectory = GetTransactionDirectory(plan.TransactionId);
|
||||
if (!IsContained(TransactionsDirectory, transactionDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update transaction path escaped.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string NormalizeTargetDirectory(string targetDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(targetDirectory);
|
||||
if (!Path.IsPathFullyQualified(targetDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update target directory must be absolute.");
|
||||
}
|
||||
|
||||
string target = Path.TrimEndingDirectorySeparator(Path.GetFullPath(targetDirectory));
|
||||
if (!Directory.Exists(target)
|
||||
|| (File.GetAttributes(target) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The self-update target directory is missing or is a reparse point.");
|
||||
}
|
||||
|
||||
return target;
|
||||
}
|
||||
|
||||
private static void EnsureSafeParent(string root, string filePath)
|
||||
{
|
||||
string? parent = Path.GetDirectoryName(filePath);
|
||||
if (parent is null)
|
||||
{
|
||||
throw new LauncherUpdateException("A self-update target has no parent.");
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(parent);
|
||||
for (var directory = new DirectoryInfo(parent);
|
||||
directory is not null && IsContained(root, directory.FullName);
|
||||
directory = directory.Parent)
|
||||
{
|
||||
if ((directory.Attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Self-update target parent '{directory.FullName}' is a reparse point.");
|
||||
}
|
||||
|
||||
if (PathsEqual(directory.FullName, root))
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsContained(string root, string path)
|
||||
{
|
||||
string fullRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root));
|
||||
string fullPath = Path.GetFullPath(path);
|
||||
return PathsEqual(fullRoot, fullPath)
|
||||
|| fullPath.StartsWith(
|
||||
fullRoot + Path.DirectorySeparatorChar,
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool PathsEqual(string left, string right) =>
|
||||
string.Equals(
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(left)),
|
||||
Path.TrimEndingDirectorySeparator(Path.GetFullPath(right)),
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal);
|
||||
|
||||
private static void RequireTransactionId(string transactionId)
|
||||
{
|
||||
if (transactionId.Length != 32
|
||||
|| !Guid.TryParseExact(transactionId, "N", out Guid parsed)
|
||||
|| !string.Equals(parsed.ToString("N"), transactionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException("The self-update transaction id is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
private void CleanupOwnedResidue(string? keepTransactionId)
|
||||
{
|
||||
if (Directory.Exists(TransactionsDirectory))
|
||||
{
|
||||
foreach (string directory in Directory.EnumerateDirectories(
|
||||
TransactionsDirectory,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string name = Path.GetFileName(directory);
|
||||
if (name.Length == 32
|
||||
&& Guid.TryParseExact(name, "N", out Guid transaction)
|
||||
&& string.Equals(
|
||||
transaction.ToString("N"),
|
||||
name,
|
||||
StringComparison.Ordinal)
|
||||
&& !string.Equals(name, keepTransactionId, StringComparison.Ordinal))
|
||||
{
|
||||
SafeZipExtractor.TryDeleteDirectory(directory);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!Directory.Exists(RootDirectory))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string temporary in Directory.EnumerateFiles(
|
||||
RootDirectory,
|
||||
".pending.json.*.tmp",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
string name = Path.GetFileName(temporary);
|
||||
string prefix = ".pending.json.";
|
||||
string transaction = name[prefix.Length..^".tmp".Length];
|
||||
if (Guid.TryParseExact(transaction, "N", out _))
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(temporary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
457
src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs
Normal file
457
src/AcDream.Launcher.Core/Updates/LauncherUpdater.cs
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public enum LauncherUpdatePhase
|
||||
{
|
||||
Idle,
|
||||
Checking,
|
||||
DownloadingClient,
|
||||
ExtractingClient,
|
||||
ActivatingClient,
|
||||
DownloadingLauncher,
|
||||
StagingLauncher,
|
||||
RollingBack,
|
||||
Completed,
|
||||
Cancelled,
|
||||
Failed,
|
||||
}
|
||||
|
||||
public sealed record LauncherUpdateProgress(
|
||||
LauncherUpdatePhase Phase,
|
||||
string Status,
|
||||
long Completed = 0,
|
||||
long Total = 0)
|
||||
{
|
||||
public double Percent => Total <= 0
|
||||
? 0
|
||||
: Math.Clamp(Completed * 100d / Total, 0, 100);
|
||||
}
|
||||
|
||||
public sealed record LauncherUpdateCheckResult(
|
||||
ReleaseManifest Manifest,
|
||||
string Rid,
|
||||
LauncherVersion LauncherVersion,
|
||||
LauncherVersion? InstalledClientVersion,
|
||||
bool IsClientUpdateAvailable,
|
||||
bool IsLauncherUpdateAvailable,
|
||||
bool IsLauncherMinimumSatisfied,
|
||||
string Status);
|
||||
|
||||
public interface ILauncherUpdater
|
||||
{
|
||||
ClientVersionResolution CurrentClient { get; }
|
||||
|
||||
Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Canonical LA10 update transaction. It holds the cross-process exclusive
|
||||
/// barrier for recovery/download/extraction/promotion/pointer publication and
|
||||
/// leaves LA9's verified DAT/pak record untouched.
|
||||
/// </summary>
|
||||
public sealed class LauncherUpdater : ILauncherUpdater
|
||||
{
|
||||
private readonly IReleaseManifestClient _manifestClient;
|
||||
private readonly ClientVersionStore _versions;
|
||||
private readonly LauncherSelfUpdateManager _selfUpdates;
|
||||
private readonly VerifiedArtifactDownloader _downloader;
|
||||
private readonly SafeZipExtractor _extractor;
|
||||
private readonly LauncherVersion _launcherVersion;
|
||||
private readonly string _rid;
|
||||
private readonly string _launcherTargetDirectory;
|
||||
private readonly Func<bool> _hasRunningSessions;
|
||||
private readonly SemaphoreSlim _operationGate = new(1, 1);
|
||||
|
||||
public LauncherUpdater(
|
||||
IReleaseManifestClient manifestClient,
|
||||
HttpClient httpClient,
|
||||
ClientVersionStore versions,
|
||||
LauncherSelfUpdateManager selfUpdates,
|
||||
LauncherVersion launcherVersion,
|
||||
string rid,
|
||||
string launcherTargetDirectory,
|
||||
Func<bool>? hasRunningSessions = null,
|
||||
SafeZipExtractor? extractor = null)
|
||||
{
|
||||
_manifestClient = manifestClient
|
||||
?? throw new ArgumentNullException(nameof(manifestClient));
|
||||
_versions = versions ?? throw new ArgumentNullException(nameof(versions));
|
||||
_selfUpdates = selfUpdates ?? throw new ArgumentNullException(nameof(selfUpdates));
|
||||
_launcherVersion = launcherVersion
|
||||
?? throw new ArgumentNullException(nameof(launcherVersion));
|
||||
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||
{
|
||||
throw new ArgumentException("RID is invalid.", nameof(rid));
|
||||
}
|
||||
|
||||
_rid = rid;
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(launcherTargetDirectory);
|
||||
_launcherTargetDirectory = Path.GetFullPath(launcherTargetDirectory);
|
||||
_hasRunningSessions = hasRunningSessions ?? (() => false);
|
||||
_downloader = new VerifiedArtifactDownloader(
|
||||
httpClient ?? throw new ArgumentNullException(nameof(httpClient)));
|
||||
_extractor = extractor ?? new SafeZipExtractor();
|
||||
}
|
||||
|
||||
public ClientVersionResolution CurrentClient => _versions.CachedResolution;
|
||||
|
||||
public Task<ClientVersionResolution> InitializeAsync(
|
||||
CancellationToken cancellationToken = default) =>
|
||||
_versions.LoadAndRecoverAsync(_rid, cancellationToken);
|
||||
|
||||
public async Task<LauncherUpdateCheckResult> CheckAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
ReleaseManifest manifest = await _manifestClient.FetchAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
_ = manifest.RequireClient(_rid);
|
||||
_ = manifest.RequireLauncher(_rid);
|
||||
ClientVersionResolution installed = _versions.CachedResolution;
|
||||
LauncherVersion? installedVersion = installed.IsVerified
|
||||
? installed.Version
|
||||
: null;
|
||||
bool clientAvailable = installedVersion is null
|
||||
|| manifest.Version > installedVersion;
|
||||
bool launcherAvailable = manifest.Version > _launcherVersion;
|
||||
bool minimumSatisfied = _launcherVersion >= manifest.MinimumLauncherVersion;
|
||||
string status = BuildCheckStatus(
|
||||
manifest,
|
||||
installedVersion,
|
||||
clientAvailable,
|
||||
launcherAvailable,
|
||||
minimumSatisfied);
|
||||
return new LauncherUpdateCheckResult(
|
||||
manifest,
|
||||
_rid,
|
||||
_launcherVersion,
|
||||
installedVersion,
|
||||
clientAvailable,
|
||||
launcherAvailable,
|
||||
minimumSatisfied,
|
||||
status);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ClientVersionResolution> InstallClientAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(check);
|
||||
ValidateCheck(check);
|
||||
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
RefuseRunningSessions();
|
||||
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||
_versions.Barrier.AcquireExclusive();
|
||||
RefuseRunningSessions();
|
||||
ClientVersionResolution current = await _versions
|
||||
.LoadAndRecoverUnderLeaseAsync(_rid, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (!check.IsLauncherMinimumSatisfied)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Client {check.Manifest.Version} requires launcher "
|
||||
+ $"{check.Manifest.MinimumLauncherVersion} or newer. "
|
||||
+ "Stage the launcher update first.");
|
||||
}
|
||||
|
||||
if (current.IsVerified
|
||||
&& current.Version is not null
|
||||
&& current.Version >= check.Manifest.Version)
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Completed,
|
||||
$"Client {current.Version} is already current.",
|
||||
1,
|
||||
1);
|
||||
return current;
|
||||
}
|
||||
|
||||
ReleaseArtifact artifact = check.Manifest.RequireClient(_rid);
|
||||
Guid transactionId = Guid.NewGuid();
|
||||
string staging = _versions.CreateClientStagingDirectory(transactionId);
|
||||
string archive = Path.Combine(
|
||||
_versions.AppDirectory,
|
||||
$".client-download-{transactionId:N}.zip");
|
||||
try
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.DownloadingClient,
|
||||
$"Downloading client {check.Manifest.Version}...",
|
||||
0,
|
||||
artifact.Size);
|
||||
var downloadProgress = new ForwardProgress<ArtifactDownloadProgress>(value =>
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.DownloadingClient,
|
||||
$"Downloading client {check.Manifest.Version}: "
|
||||
+ $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes",
|
||||
value.BytesReceived,
|
||||
value.TotalBytes));
|
||||
_ = await _downloader.DownloadAsync(
|
||||
artifact,
|
||||
archive,
|
||||
downloadProgress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.ExtractingClient,
|
||||
"Verifying paths and extracting the client archive...");
|
||||
IReadOnlyList<ExtractedFileRecord> files = await _extractor.ExtractAsync(
|
||||
archive,
|
||||
staging,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.ActivatingClient,
|
||||
$"Atomically activating client {check.Manifest.Version}...");
|
||||
ClientVersionResolution result = await _versions
|
||||
.PromoteAndActivateUnderLeaseAsync(
|
||||
staging,
|
||||
check.Manifest.Version,
|
||||
_rid,
|
||||
artifact,
|
||||
files,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Completed,
|
||||
$"Client {check.Manifest.Version} installed and activated.",
|
||||
1,
|
||||
1);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Cancelled,
|
||||
"Client update cancelled; the active version was not changed.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Failed,
|
||||
$"Client update failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
VerifiedArtifactDownloader.TryDelete(archive);
|
||||
SafeZipExtractor.TryDeleteDirectory(staging);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<SelfUpdateStageResult> StageLauncherAsync(
|
||||
LauncherUpdateCheckResult check,
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(check);
|
||||
ValidateCheck(check);
|
||||
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
RefuseRunningSessions();
|
||||
using UpdateSessionBarrier.ExclusiveLease lease =
|
||||
_versions.Barrier.AcquireExclusive();
|
||||
RefuseRunningSessions();
|
||||
if (check.Manifest.Version <= _launcherVersion)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Launcher {_launcherVersion} is already current.");
|
||||
}
|
||||
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.DownloadingLauncher,
|
||||
$"Downloading launcher {check.Manifest.Version}...");
|
||||
var downloadProgress = new ForwardProgress<ArtifactDownloadProgress>(value =>
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.DownloadingLauncher,
|
||||
$"Downloading launcher {check.Manifest.Version}: "
|
||||
+ $"{value.BytesReceived:N0}/{value.TotalBytes:N0} bytes",
|
||||
value.BytesReceived,
|
||||
value.TotalBytes));
|
||||
try
|
||||
{
|
||||
SelfUpdateStageResult result = await _selfUpdates.StageAsync(
|
||||
check.Manifest,
|
||||
_rid,
|
||||
_launcherTargetDirectory,
|
||||
downloadProgress,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.StagingLauncher,
|
||||
result.Status,
|
||||
1,
|
||||
1);
|
||||
return result;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Cancelled,
|
||||
"Launcher update staging cancelled.");
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Failed,
|
||||
$"Launcher update staging failed: {ex.Message}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ClientVersionResolution> RollbackClientAsync(
|
||||
IProgress<LauncherUpdateProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _operationGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
RefuseRunningSessions();
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.RollingBack,
|
||||
"Verifying and activating the previous client version...");
|
||||
ClientVersionResolution result = await _versions.RollbackAsync(
|
||||
_rid,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
Report(
|
||||
progress,
|
||||
LauncherUpdatePhase.Completed,
|
||||
$"Rolled back to client {result.Version}.",
|
||||
1,
|
||||
1);
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_operationGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private void ValidateCheck(LauncherUpdateCheckResult check)
|
||||
{
|
||||
if (!string.Equals(check.Rid, _rid, StringComparison.Ordinal)
|
||||
|| !check.LauncherVersion.Equals(_launcherVersion))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The update check belongs to a different launcher runtime.");
|
||||
}
|
||||
|
||||
_ = check.Manifest.RequireClient(_rid);
|
||||
_ = check.Manifest.RequireLauncher(_rid);
|
||||
}
|
||||
|
||||
private void RefuseRunningSessions()
|
||||
{
|
||||
if (_hasRunningSessions())
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Stop every launcher session before installing or rolling back an update.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildCheckStatus(
|
||||
ReleaseManifest manifest,
|
||||
LauncherVersion? installed,
|
||||
bool clientAvailable,
|
||||
bool launcherAvailable,
|
||||
bool minimumSatisfied)
|
||||
{
|
||||
if (!minimumSatisfied)
|
||||
{
|
||||
return $"Release {manifest.Version} requires launcher "
|
||||
+ $"{manifest.MinimumLauncherVersion} or newer.";
|
||||
}
|
||||
|
||||
if (clientAvailable && launcherAvailable)
|
||||
{
|
||||
return $"Client and launcher {manifest.Version} are available.";
|
||||
}
|
||||
|
||||
if (clientAvailable)
|
||||
{
|
||||
return installed is null
|
||||
? $"Client {manifest.Version} is available for installation."
|
||||
: $"Client update {installed} -> {manifest.Version} is available.";
|
||||
}
|
||||
|
||||
if (launcherAvailable)
|
||||
{
|
||||
return $"Launcher {manifest.Version} is available.";
|
||||
}
|
||||
|
||||
return "Client and launcher are up to date.";
|
||||
}
|
||||
|
||||
private static void Report(
|
||||
IProgress<LauncherUpdateProgress>? progress,
|
||||
LauncherUpdatePhase phase,
|
||||
string status,
|
||||
long completed = 0,
|
||||
long total = 0) =>
|
||||
progress?.Report(new LauncherUpdateProgress(
|
||||
phase,
|
||||
status,
|
||||
completed,
|
||||
total));
|
||||
|
||||
private sealed class ForwardProgress<T>(Action<T> callback) : IProgress<T>
|
||||
{
|
||||
public void Report(T value) => callback(value);
|
||||
}
|
||||
}
|
||||
199
src/AcDream.Launcher.Core/Updates/LauncherVersion.cs
Normal file
199
src/AcDream.Launcher.Core/Updates/LauncherVersion.cs
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
/// <summary>
|
||||
/// Strict SemVer 2.0 value used by the release feed, client pointer, and
|
||||
/// self-update plan. Numeric identifiers are compared as digit strings so a
|
||||
/// maliciously large identifier cannot overflow a fixed-width integer.
|
||||
/// </summary>
|
||||
public sealed class LauncherVersion : IComparable<LauncherVersion>, IEquatable<LauncherVersion>
|
||||
{
|
||||
private readonly string[] _core;
|
||||
private readonly string[] _preRelease;
|
||||
|
||||
private LauncherVersion(
|
||||
string value,
|
||||
string[] core,
|
||||
string[] preRelease)
|
||||
{
|
||||
Value = value;
|
||||
_core = core;
|
||||
_preRelease = preRelease;
|
||||
}
|
||||
|
||||
public string Value { get; }
|
||||
|
||||
public bool IsPreRelease => _preRelease.Length != 0;
|
||||
|
||||
public static LauncherVersion Parse(string value)
|
||||
{
|
||||
if (!TryParse(value, out LauncherVersion? version))
|
||||
{
|
||||
throw new FormatException($"'{value}' is not a strict SemVer 2.0 version.");
|
||||
}
|
||||
|
||||
return version;
|
||||
}
|
||||
|
||||
public static bool TryParse(
|
||||
string? value,
|
||||
[NotNullWhen(true)] out LauncherVersion? version)
|
||||
{
|
||||
version = null;
|
||||
if (string.IsNullOrEmpty(value)
|
||||
|| value.Length > 128
|
||||
|| !string.Equals(value, value.Trim(), StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string precedence = value;
|
||||
int plus = value.IndexOf('+', StringComparison.Ordinal);
|
||||
if (plus >= 0)
|
||||
{
|
||||
if (plus == value.Length - 1
|
||||
|| value.IndexOf('+', plus + 1) >= 0
|
||||
|| !ValidIdentifiers(value[(plus + 1)..], numericLeadingZeroRule: false))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
precedence = value[..plus];
|
||||
}
|
||||
|
||||
string coreText = precedence;
|
||||
string[] preRelease = [];
|
||||
int dash = precedence.IndexOf('-', StringComparison.Ordinal);
|
||||
if (dash >= 0)
|
||||
{
|
||||
if (dash == precedence.Length - 1
|
||||
|| !ValidIdentifiers(precedence[(dash + 1)..], numericLeadingZeroRule: true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
coreText = precedence[..dash];
|
||||
preRelease = precedence[(dash + 1)..].Split('.');
|
||||
}
|
||||
|
||||
string[] core = coreText.Split('.');
|
||||
if (core.Length != 3 || core.Any(part => !ValidCoreNumber(part)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
version = new LauncherVersion(value, core, preRelease);
|
||||
return true;
|
||||
}
|
||||
|
||||
public int CompareTo(LauncherVersion? other)
|
||||
{
|
||||
if (other is null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
for (int index = 0; index < _core.Length; index++)
|
||||
{
|
||||
int comparison = CompareNumeric(_core[index], other._core[index]);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
}
|
||||
|
||||
if (_preRelease.Length == 0 || other._preRelease.Length == 0)
|
||||
{
|
||||
return _preRelease.Length == other._preRelease.Length
|
||||
? 0
|
||||
: _preRelease.Length == 0 ? 1 : -1;
|
||||
}
|
||||
|
||||
int shared = Math.Min(_preRelease.Length, other._preRelease.Length);
|
||||
for (int index = 0; index < shared; index++)
|
||||
{
|
||||
string left = _preRelease[index];
|
||||
string right = other._preRelease[index];
|
||||
bool leftNumeric = IsDigits(left);
|
||||
bool rightNumeric = IsDigits(right);
|
||||
int comparison = leftNumeric && rightNumeric
|
||||
? CompareNumeric(left, right)
|
||||
: leftNumeric != rightNumeric
|
||||
? leftNumeric ? -1 : 1
|
||||
: string.Compare(left, right, StringComparison.Ordinal);
|
||||
if (comparison != 0)
|
||||
{
|
||||
return comparison;
|
||||
}
|
||||
}
|
||||
|
||||
return _preRelease.Length.CompareTo(other._preRelease.Length);
|
||||
}
|
||||
|
||||
public bool Equals(LauncherVersion? other) =>
|
||||
other is not null && CompareTo(other) == 0;
|
||||
|
||||
public override bool Equals(object? obj) => Equals(obj as LauncherVersion);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
var hash = new HashCode();
|
||||
foreach (string part in _core)
|
||||
{
|
||||
hash.Add(part, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
hash.Add(_preRelease.Length);
|
||||
foreach (string part in _preRelease)
|
||||
{
|
||||
hash.Add(part, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
return hash.ToHashCode();
|
||||
}
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public static bool operator >(LauncherVersion left, LauncherVersion right) =>
|
||||
left.CompareTo(right) > 0;
|
||||
|
||||
public static bool operator <(LauncherVersion left, LauncherVersion right) =>
|
||||
left.CompareTo(right) < 0;
|
||||
|
||||
public static bool operator >=(LauncherVersion left, LauncherVersion right) =>
|
||||
left.CompareTo(right) >= 0;
|
||||
|
||||
public static bool operator <=(LauncherVersion left, LauncherVersion right) =>
|
||||
left.CompareTo(right) <= 0;
|
||||
|
||||
private static bool ValidCoreNumber(string value) =>
|
||||
IsDigits(value) && (value.Length == 1 || value[0] != '0');
|
||||
|
||||
private static bool ValidIdentifiers(string value, bool numericLeadingZeroRule)
|
||||
{
|
||||
string[] identifiers = value.Split('.');
|
||||
return identifiers.All(identifier =>
|
||||
identifier.Length > 0
|
||||
&& identifier.All(character =>
|
||||
character is >= '0' and <= '9'
|
||||
or >= 'A' and <= 'Z'
|
||||
or >= 'a' and <= 'z'
|
||||
or '-')
|
||||
&& (!numericLeadingZeroRule
|
||||
|| !IsDigits(identifier)
|
||||
|| identifier.Length == 1
|
||||
|| identifier[0] != '0'));
|
||||
}
|
||||
|
||||
private static bool IsDigits(string value) =>
|
||||
value.Length > 0 && value.All(character => character is >= '0' and <= '9');
|
||||
|
||||
private static int CompareNumeric(string left, string right)
|
||||
{
|
||||
int length = left.Length.CompareTo(right.Length);
|
||||
return length != 0
|
||||
? length
|
||||
: string.Compare(left, right, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
37
src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs
Normal file
37
src/AcDream.Launcher.Core/Updates/ReleaseManifest.cs
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public sealed record ReleaseArtifact(Uri Url, string Sha256, long Size);
|
||||
|
||||
public sealed record ReleaseManifest(
|
||||
LauncherVersion Version,
|
||||
LauncherVersion MinimumLauncherVersion,
|
||||
IReadOnlyDictionary<string, ReleaseArtifact> Clients,
|
||||
IReadOnlyDictionary<string, ReleaseArtifact> Launchers)
|
||||
{
|
||||
public const int CurrentSchemaVersion = 1;
|
||||
|
||||
public ReleaseArtifact RequireClient(string rid) =>
|
||||
Clients.TryGetValue(rid, out ReleaseArtifact? artifact)
|
||||
? artifact
|
||||
: throw new LauncherUpdateException(
|
||||
$"Release {Version} has no client payload for RID '{rid}'.");
|
||||
|
||||
public ReleaseArtifact RequireLauncher(string rid) =>
|
||||
Launchers.TryGetValue(rid, out ReleaseArtifact? artifact)
|
||||
? artifact
|
||||
: throw new LauncherUpdateException(
|
||||
$"Release {Version} has no launcher payload for RID '{rid}'.");
|
||||
}
|
||||
|
||||
public sealed class LauncherUpdateException : Exception
|
||||
{
|
||||
public LauncherUpdateException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
|
||||
public LauncherUpdateException(string message, Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
}
|
||||
}
|
||||
300
src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
Normal file
300
src/AcDream.Launcher.Core/Updates/ReleaseManifestClient.cs
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public interface IReleaseManifestClient
|
||||
{
|
||||
Task<ReleaseManifest> FetchAsync(CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// </summary>
|
||||
public sealed class ReleaseManifestClient : IReleaseManifestClient, IDisposable
|
||||
{
|
||||
public const string GitHubOwner = "eriknihlen";
|
||||
public const string GitHubRepository = "acdream";
|
||||
public const int MaximumManifestBytes = 1024 * 1024;
|
||||
public const long MaximumArtifactBytes = 4L * 1024 * 1024 * 1024;
|
||||
|
||||
public static Uri ProductionManifestUri { get; } = new(
|
||||
$"https://github.com/{GitHubOwner}/{GitHubRepository}/releases/latest/download/manifest.json");
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
PropertyNameCaseInsensitive = false,
|
||||
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
|
||||
MaxDepth = 16,
|
||||
};
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly bool _ownsHttpClient;
|
||||
private readonly Uri _manifestUri;
|
||||
|
||||
public ReleaseManifestClient(HttpClient? httpClient = null, Uri? manifestUri = null)
|
||||
{
|
||||
_httpClient = httpClient ?? new HttpClient();
|
||||
_ownsHttpClient = httpClient is null;
|
||||
_manifestUri = manifestUri ?? ProductionManifestUri;
|
||||
RequireSecureOrLoopback(_manifestUri, "manifest");
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("acdream-launcher/1");
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException
|
||||
or IOException
|
||||
or JsonException
|
||||
or NotSupportedException)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest could not be loaded: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static ReleaseManifest Parse(ReadOnlySpan<byte> utf8)
|
||||
{
|
||||
try
|
||||
{
|
||||
using JsonDocument document = JsonDocument.Parse(
|
||||
utf8.ToArray(),
|
||||
new JsonDocumentOptions
|
||||
{
|
||||
AllowTrailingCommas = false,
|
||||
CommentHandling = JsonCommentHandling.Disallow,
|
||||
MaxDepth = 16,
|
||||
});
|
||||
RejectDuplicateProperties(document.RootElement, "$" );
|
||||
ManifestDocument? value = document.RootElement.Deserialize<ManifestDocument>(
|
||||
SerializerOptions);
|
||||
return Validate(value);
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is JsonException
|
||||
or FormatException
|
||||
or InvalidOperationException)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The release manifest is invalid: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_ownsHttpClient)
|
||||
{
|
||||
_httpClient.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
internal static void RequireSecureOrLoopback(Uri uri, string description)
|
||||
{
|
||||
if (!uri.IsAbsoluteUri
|
||||
|| (uri.Scheme != Uri.UriSchemeHttps
|
||||
&& !(uri.Scheme == Uri.UriSchemeHttp && uri.IsLoopback)))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The {description} URI must use HTTPS (loopback HTTP is test-only).");
|
||||
}
|
||||
}
|
||||
|
||||
private static ReleaseManifest Validate(ManifestDocument? document)
|
||||
{
|
||||
if (document is null)
|
||||
{
|
||||
throw new LauncherUpdateException("The release manifest is empty.");
|
||||
}
|
||||
|
||||
if (document.SchemaVersion != ReleaseManifest.CurrentSchemaVersion)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Release manifest schema version {document.SchemaVersion} is not supported.");
|
||||
}
|
||||
|
||||
LauncherVersion version = LauncherVersion.Parse(
|
||||
document.Version
|
||||
?? throw new LauncherUpdateException("The release version is missing."));
|
||||
LauncherVersion minimum = LauncherVersion.Parse(
|
||||
document.MinimumLauncherVersion
|
||||
?? throw new LauncherUpdateException(
|
||||
"The minimum launcher version is missing."));
|
||||
if (minimum > version)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The minimum launcher version cannot exceed the release version.");
|
||||
}
|
||||
IReadOnlyDictionary<string, ReleaseArtifact> clients = ValidateArtifacts(
|
||||
document.Clients,
|
||||
"clients");
|
||||
IReadOnlyDictionary<string, ReleaseArtifact> launchers = ValidateArtifacts(
|
||||
document.Launchers,
|
||||
"launchers");
|
||||
return new ReleaseManifest(version, minimum, clients, launchers);
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, ReleaseArtifact> ValidateArtifacts(
|
||||
Dictionary<string, ArtifactDocument>? artifacts,
|
||||
string field)
|
||||
{
|
||||
if (artifacts is null || artifacts.Count == 0)
|
||||
{
|
||||
throw new LauncherUpdateException($"Manifest field '{field}' must not be empty.");
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, ReleaseArtifact>(StringComparer.Ordinal);
|
||||
foreach ((string rid, ArtifactDocument value) in artifacts)
|
||||
{
|
||||
if (!LauncherRuntimeIdentity.IsValidRid(rid))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Manifest field '{field}' contains invalid RID '{rid}'.");
|
||||
}
|
||||
|
||||
if (value is null)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Manifest payload '{field}.{rid}' is null.");
|
||||
}
|
||||
|
||||
if (!Uri.TryCreate(value.Url, UriKind.Absolute, out Uri? uri))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Manifest payload '{field}.{rid}' has an invalid URL.");
|
||||
}
|
||||
|
||||
RequireSecureOrLoopback(uri, $"{field}.{rid} artifact");
|
||||
if (!IsSha256(value.Sha256))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Manifest payload '{field}.{rid}' has an invalid SHA-256 digest.");
|
||||
}
|
||||
|
||||
if (value.Size <= 0 || value.Size > MaximumArtifactBytes)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Manifest payload '{field}.{rid}' has an invalid size.");
|
||||
}
|
||||
|
||||
result.Add(
|
||||
rid,
|
||||
new ReleaseArtifact(uri, value.Sha256!.ToLowerInvariant(), value.Size));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
internal static bool IsSha256(string? value) =>
|
||||
value is { Length: 64 } && value.All(Uri.IsHexDigit);
|
||||
|
||||
private static void RejectDuplicateProperties(JsonElement element, string path)
|
||||
{
|
||||
if (element.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
var names = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (JsonProperty property in element.EnumerateObject())
|
||||
{
|
||||
if (!names.Add(property.Name))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Duplicate JSON property '{path}.{property.Name}' is not allowed.");
|
||||
}
|
||||
|
||||
RejectDuplicateProperties(property.Value, $"{path}.{property.Name}");
|
||||
}
|
||||
}
|
||||
else if (element.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
int index = 0;
|
||||
foreach (JsonElement item in element.EnumerateArray())
|
||||
{
|
||||
RejectDuplicateProperties(item, $"{path}[{index++}]");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class ManifestDocument
|
||||
{
|
||||
public int SchemaVersion { get; init; }
|
||||
|
||||
public string? Version { get; init; }
|
||||
|
||||
public string? MinimumLauncherVersion { get; init; }
|
||||
|
||||
public Dictionary<string, ArtifactDocument>? Clients { get; init; }
|
||||
|
||||
public Dictionary<string, ArtifactDocument>? Launchers { get; init; }
|
||||
}
|
||||
|
||||
private sealed class ArtifactDocument
|
||||
{
|
||||
public string? Url { get; init; }
|
||||
|
||||
public string? Sha256 { get; init; }
|
||||
|
||||
public long Size { get; init; }
|
||||
}
|
||||
}
|
||||
482
src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs
Normal file
482
src/AcDream.Launcher.Core/Updates/SafeZipExtractor.cs
Normal file
|
|
@ -0,0 +1,482 @@
|
|||
using System.Buffers;
|
||||
using System.IO.Compression;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public sealed record SafeZipExtractionLimits(
|
||||
int MaximumEntries = 20_000,
|
||||
long MaximumEntryBytes = 2L * 1024 * 1024 * 1024,
|
||||
long MaximumTotalBytes = 8L * 1024 * 1024 * 1024,
|
||||
double MaximumCompressionRatio = 200,
|
||||
int MaximumRelativePathLength = 512);
|
||||
|
||||
public sealed record ExtractedFileRecord(
|
||||
string Path,
|
||||
string Sha256,
|
||||
long Size,
|
||||
int UnixMode);
|
||||
|
||||
/// <summary>
|
||||
/// Portable ZIP extractor for release assets. The complete central-directory
|
||||
/// shape is validated before the first output path is created.
|
||||
/// </summary>
|
||||
public sealed class SafeZipExtractor
|
||||
{
|
||||
private const int BufferSize = 128 * 1024;
|
||||
private const int UnixTypeMask = 0xF000;
|
||||
private const int UnixRegularFile = 0x8000;
|
||||
private const int UnixDirectory = 0x4000;
|
||||
private const int UnixPermissionMask = 0x1FF;
|
||||
private readonly SafeZipExtractionLimits _limits;
|
||||
|
||||
public SafeZipExtractor(SafeZipExtractionLimits? limits = null)
|
||||
{
|
||||
_limits = limits ?? new SafeZipExtractionLimits();
|
||||
if (_limits.MaximumEntries <= 0
|
||||
|| _limits.MaximumEntryBytes <= 0
|
||||
|| _limits.MaximumTotalBytes <= 0
|
||||
|| _limits.MaximumCompressionRatio <= 0
|
||||
|| _limits.MaximumRelativePathLength <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(limits),
|
||||
"ZIP extraction limits must all be positive.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ExtractedFileRecord>> ExtractAsync(
|
||||
string archivePath,
|
||||
string destinationDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(archivePath);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory);
|
||||
string archive = Path.GetFullPath(archivePath);
|
||||
string destination = Path.GetFullPath(destinationDirectory);
|
||||
|
||||
if (Directory.Exists(destination)
|
||||
&& Directory.EnumerateFileSystemEntries(destination).Any())
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"The ZIP extraction destination must be empty.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = new FileStream(
|
||||
archive,
|
||||
FileMode.Open,
|
||||
FileAccess.Read,
|
||||
FileShare.Read,
|
||||
BufferSize,
|
||||
FileOptions.Asynchronous | FileOptions.SequentialScan);
|
||||
using var zip = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
|
||||
IReadOnlyList<ValidatedEntry> entries = ValidateArchive(zip);
|
||||
|
||||
Directory.CreateDirectory(destination);
|
||||
RejectReparsePoint(destination, "extraction root");
|
||||
foreach (string directory in entries
|
||||
.SelectMany(entry => ParentPaths(entry.RelativePath))
|
||||
.Concat(entries.Where(entry => entry.IsDirectory)
|
||||
.Select(entry => entry.RelativePath))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(path => path.Count(character => character == '/'))
|
||||
.ThenBy(path => path, StringComparer.Ordinal))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string directoryPath = ResolveContained(destination, directory);
|
||||
Directory.CreateDirectory(directoryPath);
|
||||
RejectReparsePoint(directoryPath, $"directory '{directory}'");
|
||||
}
|
||||
|
||||
var files = new List<ExtractedFileRecord>();
|
||||
long actualTotal = 0;
|
||||
foreach (ValidatedEntry entry in entries.Where(entry => !entry.IsDirectory))
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
string outputPath = ResolveContained(destination, entry.RelativePath);
|
||||
EnsureParentsAreDirectories(destination, entry.RelativePath);
|
||||
await using Stream input = entry.Entry.Open();
|
||||
await using var output = new FileStream(
|
||||
outputPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
FileOptions.Asynchronous
|
||||
| FileOptions.SequentialScan
|
||||
| FileOptions.WriteThrough);
|
||||
using IncrementalHash hash = IncrementalHash.CreateHash(
|
||||
HashAlgorithmName.SHA256);
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
long actualEntry = 0;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(
|
||||
buffer.AsMemory(0, BufferSize),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
actualEntry = checked(actualEntry + read);
|
||||
actualTotal = checked(actualTotal + read);
|
||||
if (actualEntry > entry.Entry.Length
|
||||
|| actualEntry > _limits.MaximumEntryBytes
|
||||
|| actualTotal > _limits.MaximumTotalBytes)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP entry '{entry.RelativePath}' exceeded its declared limits.");
|
||||
}
|
||||
|
||||
hash.AppendData(buffer, 0, read);
|
||||
await output.WriteAsync(
|
||||
buffer.AsMemory(0, read),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
output.Flush(flushToDisk: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
|
||||
if (actualEntry != entry.Entry.Length)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP entry '{entry.RelativePath}' length changed while extracting.");
|
||||
}
|
||||
|
||||
int unixMode = entry.UnixMode & UnixPermissionMask;
|
||||
if (OperatingSystem.IsLinux() && unixMode != 0)
|
||||
{
|
||||
File.SetUnixFileMode(outputPath, (UnixFileMode)unixMode);
|
||||
}
|
||||
|
||||
files.Add(new ExtractedFileRecord(
|
||||
entry.RelativePath,
|
||||
Convert.ToHexStringLower(hash.GetHashAndReset()),
|
||||
actualEntry,
|
||||
unixMode));
|
||||
}
|
||||
|
||||
files.Sort((left, right) => string.Compare(
|
||||
left.Path,
|
||||
right.Path,
|
||||
StringComparison.Ordinal));
|
||||
return files;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
TryDeleteDirectory(destination);
|
||||
throw;
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
TryDeleteDirectory(destination);
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException
|
||||
or UnauthorizedAccessException
|
||||
or InvalidDataException
|
||||
or NotSupportedException
|
||||
or CryptographicException)
|
||||
{
|
||||
TryDeleteDirectory(destination);
|
||||
throw new LauncherUpdateException(
|
||||
$"The release ZIP could not be extracted safely: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<ValidatedEntry> ValidateArchive(ZipArchive zip)
|
||||
{
|
||||
if (zip.Entries.Count == 0 || zip.Entries.Count > _limits.MaximumEntries)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP entry count {zip.Entries.Count} is outside the allowed range.");
|
||||
}
|
||||
|
||||
var result = new List<ValidatedEntry>(zip.Entries.Count);
|
||||
var explicitEntries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
var nodes = new Dictionary<string, PathNode>(StringComparer.OrdinalIgnoreCase);
|
||||
long totalLength = 0;
|
||||
long totalCompressed = 0;
|
||||
foreach (ZipArchiveEntry entry in zip.Entries)
|
||||
{
|
||||
string relative = NormalizeEntryPath(entry.FullName);
|
||||
if (!explicitEntries.Add(relative))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP contains a duplicate/case-colliding entry '{relative}'.");
|
||||
}
|
||||
|
||||
int unixAttributes = entry.ExternalAttributes >> 16;
|
||||
int unixType = unixAttributes & UnixTypeMask;
|
||||
bool trailingDirectory = entry.FullName.EndsWith("/", StringComparison.Ordinal)
|
||||
|| entry.FullName.EndsWith("\\", StringComparison.Ordinal);
|
||||
bool isDirectory = trailingDirectory || unixType == UnixDirectory;
|
||||
if ((entry.ExternalAttributes & (int)FileAttributes.ReparsePoint) != 0
|
||||
|| unixType is not (0 or UnixRegularFile or UnixDirectory)
|
||||
|| (unixType == UnixDirectory && !trailingDirectory)
|
||||
|| (isDirectory && (entry.Length != 0 || entry.CompressedLength != 0)))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP entry '{relative}' is a symlink, reparse point, or unsupported type.");
|
||||
}
|
||||
|
||||
AddPathNodes(nodes, relative, isDirectory);
|
||||
if (!isDirectory)
|
||||
{
|
||||
if (entry.Length < 0
|
||||
|| entry.CompressedLength < 0
|
||||
|| entry.Length > _limits.MaximumEntryBytes)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP entry '{relative}' exceeds the per-file limit.");
|
||||
}
|
||||
|
||||
totalLength = checked(totalLength + entry.Length);
|
||||
totalCompressed = checked(totalCompressed + entry.CompressedLength);
|
||||
if (totalLength > _limits.MaximumTotalBytes
|
||||
|| IsRatioExceeded(entry.Length, entry.CompressedLength))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP entry '{relative}' exceeds extraction size/ratio limits.");
|
||||
}
|
||||
}
|
||||
|
||||
result.Add(new ValidatedEntry(entry, relative, isDirectory, unixAttributes));
|
||||
}
|
||||
|
||||
if (totalLength > 0
|
||||
&& (totalCompressed == 0 || IsRatioExceeded(totalLength, totalCompressed)))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"ZIP aggregate compression ratio exceeds the allowed limit.");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private string NormalizeEntryPath(string name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)
|
||||
|| name.IndexOf('\0') >= 0
|
||||
|| name.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException("ZIP contains an empty, NUL, or ADS path.");
|
||||
}
|
||||
|
||||
string normalized = name.Replace('\\', '/');
|
||||
bool directory = normalized.EndsWith("/", StringComparison.Ordinal);
|
||||
normalized = normalized.TrimEnd('/');
|
||||
if (normalized.Length == 0
|
||||
|| normalized.Length > _limits.MaximumRelativePathLength
|
||||
|| normalized.StartsWith("/", StringComparison.Ordinal)
|
||||
|| Path.IsPathRooted(normalized))
|
||||
{
|
||||
throw new LauncherUpdateException($"ZIP path '{name}' is rooted or too long.");
|
||||
}
|
||||
|
||||
string[] segments = normalized.Split('/');
|
||||
foreach (string segment in segments)
|
||||
{
|
||||
if (segment.Length == 0
|
||||
|| segment is "." or ".."
|
||||
|| segment.EndsWith(' ')
|
||||
|| segment.EndsWith('.')
|
||||
|| segment.Any(character =>
|
||||
char.IsControl(character)
|
||||
|| character is '<' or '>' or '"' or '|' or '?' or '*')
|
||||
|| IsWindowsDeviceName(segment))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP path '{name}' contains an unsafe segment.");
|
||||
}
|
||||
}
|
||||
|
||||
return string.Join('/', segments) + (directory ? "/" : string.Empty);
|
||||
}
|
||||
|
||||
private static void AddPathNodes(
|
||||
Dictionary<string, PathNode> nodes,
|
||||
string relative,
|
||||
bool isDirectory)
|
||||
{
|
||||
string path = relative.TrimEnd('/');
|
||||
string[] segments = path.Split('/');
|
||||
string current = string.Empty;
|
||||
for (int index = 0; index < segments.Length; index++)
|
||||
{
|
||||
current = current.Length == 0
|
||||
? segments[index]
|
||||
: current + "/" + segments[index];
|
||||
bool nodeIsDirectory = index < segments.Length - 1 || isDirectory;
|
||||
if (nodes.TryGetValue(current, out PathNode? existing))
|
||||
{
|
||||
if (!string.Equals(existing.Spelling, current, StringComparison.Ordinal)
|
||||
|| (!existing.IsDirectory || !nodeIsDirectory))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP path '{relative}' collides with '{existing.Spelling}'.");
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
nodes.Add(current, new PathNode(current, nodeIsDirectory));
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsRatioExceeded(long expanded, long compressed) =>
|
||||
expanded > 0
|
||||
&& (compressed <= 0 || expanded / (double)compressed > _limits.MaximumCompressionRatio);
|
||||
|
||||
private static IEnumerable<string> ParentPaths(string relative)
|
||||
{
|
||||
string path = relative.TrimEnd('/');
|
||||
int slash = path.IndexOf('/');
|
||||
while (slash >= 0)
|
||||
{
|
||||
yield return path[..slash];
|
||||
slash = path.IndexOf('/', slash + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveContained(string root, string relative)
|
||||
{
|
||||
string path = Path.GetFullPath(
|
||||
Path.Combine(root, relative.TrimEnd('/').Replace('/', Path.DirectorySeparatorChar)));
|
||||
string prefix = Path.EndsInDirectorySeparator(root)
|
||||
? root
|
||||
: root + Path.DirectorySeparatorChar;
|
||||
if (!path.StartsWith(
|
||||
prefix,
|
||||
OperatingSystem.IsWindows()
|
||||
? StringComparison.OrdinalIgnoreCase
|
||||
: StringComparison.Ordinal))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP path '{relative}' escaped the extraction directory.");
|
||||
}
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
private static void EnsureParentsAreDirectories(string root, string relative)
|
||||
{
|
||||
foreach (string parent in ParentPaths(relative))
|
||||
{
|
||||
string path = ResolveContained(root, parent);
|
||||
if (!Directory.Exists(path))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"ZIP parent '{parent}' is not a directory.");
|
||||
}
|
||||
|
||||
RejectReparsePoint(path, $"directory '{parent}'");
|
||||
}
|
||||
}
|
||||
|
||||
private static void RejectReparsePoint(string path, string description)
|
||||
{
|
||||
if ((File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The {description} is a reparse point.");
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
if (Directory.Exists(path))
|
||||
{
|
||||
DeleteDirectoryWithoutFollowingReparsePoints(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The exact random staging name is reclaimed under the update lease.
|
||||
}
|
||||
}
|
||||
|
||||
private static void DeleteDirectoryWithoutFollowingReparsePoints(string directory)
|
||||
{
|
||||
FileAttributes rootAttributes = File.GetAttributes(directory);
|
||||
if ((rootAttributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
DeleteReparsePoint(directory);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (string entry in Directory.EnumerateFileSystemEntries(
|
||||
directory,
|
||||
"*",
|
||||
SearchOption.TopDirectoryOnly))
|
||||
{
|
||||
FileAttributes attributes = File.GetAttributes(entry);
|
||||
if ((attributes & FileAttributes.ReparsePoint) != 0)
|
||||
{
|
||||
DeleteReparsePoint(entry);
|
||||
}
|
||||
else if ((attributes & FileAttributes.Directory) != 0)
|
||||
{
|
||||
DeleteDirectoryWithoutFollowingReparsePoints(entry);
|
||||
}
|
||||
else
|
||||
{
|
||||
File.Delete(entry);
|
||||
}
|
||||
}
|
||||
|
||||
Directory.Delete(directory, recursive: false);
|
||||
}
|
||||
|
||||
private static void DeleteReparsePoint(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
Directory.Delete(path, recursive: false);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
Directory.Delete(path, recursive: false);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record PathNode(string Spelling, bool IsDirectory);
|
||||
|
||||
private sealed record ValidatedEntry(
|
||||
ZipArchiveEntry Entry,
|
||||
string RelativePath,
|
||||
bool IsDirectory,
|
||||
int UnixMode);
|
||||
}
|
||||
85
src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
Normal file
85
src/AcDream.Launcher.Core/Updates/UpdateSessionBarrier.cs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
/// <summary>
|
||||
/// One portable OS-handle barrier shared by supervised sessions and held
|
||||
/// exclusively by update/rollback/recovery transactions. File contents are
|
||||
/// never authoritative.
|
||||
/// </summary>
|
||||
public sealed class UpdateSessionBarrier
|
||||
{
|
||||
public const string LockFileName = ".update-session.lock";
|
||||
|
||||
private readonly string _lockPath;
|
||||
|
||||
public UpdateSessionBarrier(string dataDirectory)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
|
||||
_lockPath = Path.Combine(
|
||||
Path.GetFullPath(dataDirectory),
|
||||
"app",
|
||||
LockFileName);
|
||||
}
|
||||
|
||||
public string LockPath => _lockPath;
|
||||
|
||||
public SessionLease AcquireSession()
|
||||
{
|
||||
FileStream stream = Open(FileShare.ReadWrite, "A client update is in progress.");
|
||||
return new SessionLease(stream);
|
||||
}
|
||||
|
||||
public ExclusiveLease AcquireExclusive()
|
||||
{
|
||||
FileStream stream = Open(
|
||||
FileShare.None,
|
||||
"A launcher session or another update transaction is running. "
|
||||
+ "Stop every launcher session before updating.");
|
||||
return new ExclusiveLease(stream);
|
||||
}
|
||||
|
||||
private FileStream Open(FileShare share, string refusal)
|
||||
{
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(_lockPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The update/session lock path has no parent directory."));
|
||||
try
|
||||
{
|
||||
return new FileStream(
|
||||
_lockPath,
|
||||
FileMode.OpenOrCreate,
|
||||
FileAccess.ReadWrite,
|
||||
share,
|
||||
bufferSize: 1,
|
||||
FileOptions.None);
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
throw new LauncherUpdateException(refusal, ex);
|
||||
}
|
||||
catch (UnauthorizedAccessException ex)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"The update/session lease could not be opened: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class SessionLease : IDisposable
|
||||
{
|
||||
private FileStream? _stream;
|
||||
|
||||
internal SessionLease(FileStream stream) => _stream = stream;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
||||
}
|
||||
|
||||
public sealed class ExclusiveLease : IDisposable
|
||||
{
|
||||
private FileStream? _stream;
|
||||
|
||||
internal ExclusiveLease(FileStream stream) => _stream = stream;
|
||||
|
||||
public void Dispose() => Interlocked.Exchange(ref _stream, null)?.Dispose();
|
||||
}
|
||||
}
|
||||
200
src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs
Normal file
200
src/AcDream.Launcher.Core/Updates/VerifiedArtifactDownloader.cs
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
using System.Buffers;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AcDream.Launcher.Core.Updates;
|
||||
|
||||
public sealed record ArtifactDownloadProgress(long BytesReceived, long TotalBytes)
|
||||
{
|
||||
public double Percent => TotalBytes <= 0
|
||||
? 0
|
||||
: Math.Clamp(BytesReceived * 100d / TotalBytes, 0, 100);
|
||||
}
|
||||
|
||||
public sealed record VerifiedArtifactDownload(
|
||||
string FilePath,
|
||||
long Size,
|
||||
string Sha256);
|
||||
|
||||
/// <summary>
|
||||
/// Streams a bounded release asset directly to a caller-owned staging path,
|
||||
/// computing SHA-256 during the write. A partial/cancelled/wrong artifact is
|
||||
/// deleted before the call returns.
|
||||
/// </summary>
|
||||
public sealed class VerifiedArtifactDownloader
|
||||
{
|
||||
private const int BufferSize = 128 * 1024;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
public VerifiedArtifactDownloader(HttpClient httpClient)
|
||||
{
|
||||
_httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
|
||||
}
|
||||
|
||||
public async Task<VerifiedArtifactDownload> DownloadAsync(
|
||||
ReleaseArtifact artifact,
|
||||
string destinationPath,
|
||||
IProgress<ArtifactDownloadProgress>? progress = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(artifact);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath);
|
||||
ReleaseManifestClient.RequireSecureOrLoopback(artifact.Url, "artifact");
|
||||
if (artifact.Size <= 0
|
||||
|| artifact.Size > ReleaseManifestClient.MaximumArtifactBytes
|
||||
|| !ReleaseManifestClient.IsSha256(artifact.Sha256))
|
||||
{
|
||||
throw new LauncherUpdateException("The requested artifact metadata is invalid.");
|
||||
}
|
||||
|
||||
string fullPath = Path.GetFullPath(destinationPath);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(fullPath)
|
||||
?? throw new InvalidOperationException(
|
||||
"The artifact staging path has no parent directory."));
|
||||
|
||||
bool ownsDestination = false;
|
||||
try
|
||||
{
|
||||
using HttpResponseMessage response = await _httpClient.GetAsync(
|
||||
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)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Artifact size header mismatch: expected {artifact.Size}, "
|
||||
+ $"received {contentLength}.");
|
||||
}
|
||||
|
||||
if (response.Content.Headers.ContentEncoding.Count != 0)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Release artifact content encoding is not allowed.");
|
||||
}
|
||||
|
||||
await using Stream input = await response.Content
|
||||
.ReadAsStreamAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
await using var output = new FileStream(
|
||||
fullPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None,
|
||||
BufferSize,
|
||||
FileOptions.Asynchronous
|
||||
| FileOptions.SequentialScan
|
||||
| FileOptions.WriteThrough);
|
||||
ownsDestination = true;
|
||||
using IncrementalHash hash = IncrementalHash.CreateHash(HashAlgorithmName.SHA256);
|
||||
byte[] buffer = ArrayPool<byte>.Shared.Rent(BufferSize);
|
||||
long received = 0;
|
||||
try
|
||||
{
|
||||
progress?.Report(new ArtifactDownloadProgress(0, artifact.Size));
|
||||
while (true)
|
||||
{
|
||||
int read = await input.ReadAsync(
|
||||
buffer.AsMemory(0, BufferSize),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
if (read == 0)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
received = checked(received + read);
|
||||
if (received > artifact.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Artifact exceeded its declared size of {artifact.Size} bytes.");
|
||||
}
|
||||
|
||||
hash.AppendData(buffer, 0, read);
|
||||
await output.WriteAsync(
|
||||
buffer.AsMemory(0, read),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
progress?.Report(new ArtifactDownloadProgress(received, artifact.Size));
|
||||
}
|
||||
|
||||
await output.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
output.Flush(flushToDisk: true);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(buffer, clearArray: true);
|
||||
}
|
||||
|
||||
if (received != artifact.Size)
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
$"Artifact ended at {received} bytes; expected {artifact.Size}.");
|
||||
}
|
||||
|
||||
string actualSha256 = Convert.ToHexStringLower(hash.GetHashAndReset());
|
||||
if (!string.Equals(
|
||||
actualSha256,
|
||||
artifact.Sha256,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
throw new LauncherUpdateException(
|
||||
"Artifact SHA-256 does not match the release manifest.");
|
||||
}
|
||||
|
||||
return new VerifiedArtifactDownload(fullPath, received, actualSha256);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
if (ownsDestination)
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (LauncherUpdateException)
|
||||
{
|
||||
if (ownsDestination)
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException
|
||||
or IOException
|
||||
or UnauthorizedAccessException
|
||||
or CryptographicException)
|
||||
{
|
||||
if (ownsDestination)
|
||||
{
|
||||
TryDelete(fullPath);
|
||||
}
|
||||
|
||||
throw new LauncherUpdateException(
|
||||
$"The release artifact could not be downloaded: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// The exact random staging name is reclaimed by startup recovery.
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue