feat(launcher): implement verified atomic updates
This commit is contained in:
parent
2198a0cc8e
commit
2d2a5b5046
34 changed files with 6755 additions and 61 deletions
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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue