786 lines
29 KiB
C#
786 lines
29 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
}
|