1015 lines
35 KiB
C#
1015 lines
35 KiB
C#
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",
|
|
OperatingSystem.IsWindows()
|
|
? StringComparison.OrdinalIgnoreCase
|
|
: StringComparison.Ordinal))
|
|
.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))
|
|
{
|
|
if (HasCanonicalGuidName(
|
|
Path.GetFileName(path),
|
|
".client-staging-",
|
|
string.Empty))
|
|
{
|
|
SafeZipExtractor.TryDeleteDirectory(path);
|
|
}
|
|
}
|
|
|
|
foreach (string path in Directory.EnumerateDirectories(
|
|
AppDirectory,
|
|
".client-corrupt-*",
|
|
SearchOption.TopDirectoryOnly))
|
|
{
|
|
if (HasCanonicalGuidName(
|
|
Path.GetFileName(path),
|
|
".client-corrupt-",
|
|
string.Empty))
|
|
{
|
|
SafeZipExtractor.TryDeleteDirectory(path);
|
|
}
|
|
}
|
|
|
|
foreach (string path in Directory.EnumerateFiles(
|
|
AppDirectory,
|
|
".client-download-*.zip",
|
|
SearchOption.TopDirectoryOnly))
|
|
{
|
|
if (HasCanonicalGuidName(
|
|
Path.GetFileName(path),
|
|
".client-download-",
|
|
".zip"))
|
|
{
|
|
VerifiedArtifactDownloader.TryDelete(path);
|
|
}
|
|
}
|
|
|
|
foreach (string path in Directory.EnumerateFiles(
|
|
AppDirectory,
|
|
".current*.tmp",
|
|
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 Guid parsed)
|
|
&& string.Equals(
|
|
parsed.ToString("N"),
|
|
parts[^2],
|
|
StringComparison.Ordinal))
|
|
{
|
|
VerifiedArtifactDownloader.TryDelete(path);
|
|
}
|
|
}
|
|
}
|
|
|
|
private static bool HasCanonicalGuidName(
|
|
string fileName,
|
|
string prefix,
|
|
string suffix)
|
|
{
|
|
if (!fileName.StartsWith(prefix, StringComparison.Ordinal)
|
|
|| !fileName.EndsWith(suffix, StringComparison.Ordinal)
|
|
|| fileName.Length != prefix.Length + 32 + suffix.Length)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
string value = fileName.Substring(prefix.Length, 32);
|
|
return Guid.TryParseExact(value, "N", out Guid parsed)
|
|
&& string.Equals(parsed.ToString("N"), value, StringComparison.Ordinal);
|
|
}
|
|
|
|
private void RequireOwnedStagingPath(string path)
|
|
{
|
|
string parent = Path.GetDirectoryName(path) ?? string.Empty;
|
|
string fileName = Path.GetFileName(path);
|
|
if (!PathsEqual(parent, AppDirectory)
|
|
|| !HasCanonicalGuidName(
|
|
fileName,
|
|
".client-staging-",
|
|
string.Empty))
|
|
{
|
|
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 '*')
|
|
&& !PortablePathRules.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 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);
|
|
}
|