feat(launcher): LU1 — stop hashing 28 GB before the launcher window appears

Measured on the user's machine: %LOCALAPPDATA%\acdream\pak\acdream.pak is
29,908,271,024 bytes and SHA-256 over it takes 24.1 s at 1.16 GB/s. App
.OnFrameworkInitializationCompleted ran exactly that hash synchronously,
before constructing the window, and the digest came back identical to the one
install.json already recorded. So the launcher took roughly half a minute to
appear in order to re-confirm a fact that had not changed. A friend does not
see it only because they have no package installed yet — verification
short-circuits at "nothing installed" — so it would hit them the moment
first-run setup finished.

Startup now checks the cheap facts (size, last-write time) and skips only the
hash, and only when a previous FULL hash of that same file agreed with the
install record. Everything that should hash still does: install, update, the
crash-recovery backup path, and a new explicit "Verify files" button.

The remembered fact lives in a SIDECAR (install.verification.json), not as a
new field on the install record: LauncherInstallRecordStore reads install.json
with JsonUnmappedMemberHandling.Disallow, so a new property there would make an
older launcher build reject the record outright and demand a fresh ~28 GB bake
after a rollback. An unknown sidecar is simply ignored by builds that predate
it. The cache type never throws — it sits in front of a guarantee, so every
failure mode (missing, corrupt, unknown schema, unwritable) degrades to
"hash it again" rather than to a failed launch.

Two subtleties worth keeping:
- The write time is re-read after the hash and the entry is only written when
  it is unchanged. A writer racing a multi-second hash would otherwise be
  remembered under the OLD timestamp, and the next startup would trust a
  digest that never covered those bytes.
- A hash that disagrees with the record invalidates the entry, so a stale
  "verified" fact cannot outlive the evidence that produced it.

Tests: PreparedAssetVerificationCacheTests (10) counts hash invocations through
the store's injectable hasher and covers second-startup skip, forced full
verification, touched package, same-size silent corruption, resize, a cache
digest that disagrees with the record, three unreadable-cache shapes, and
backup recovery still hashing. Plus two LauncherWindowViewModel tests for the
Verify files command. Launcher.Core 335 passed, Launcher 57 passed.

Note for the first run after this ships: the very first startup still pays one
full hash to learn the digest for the installed file, and every startup after
that is instant.

Campaign LU slice LU1. Plan: docs/plans/2026-08-19-launcher-usability-campaign.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 18:40:34 +02:00
parent a34e8f2a17
commit 00d1278228
8 changed files with 665 additions and 22 deletions

View file

@ -41,6 +41,7 @@ public sealed class LauncherInstallRecordStore
private readonly ApplicationPathSet _paths;
private readonly DatDirectoryLocator _datDirectories;
private readonly Func<string, CancellationToken, Task<string>> _computeSha256;
private readonly PreparedAssetVerificationCache _verificationCache;
public LauncherInstallRecordStore(
ApplicationPathSet paths,
@ -52,6 +53,7 @@ public sealed class LauncherInstallRecordStore
_computeSha256 = computeSha256
?? ((path, cancellationToken) =>
FileIntegrity.ComputeSha256HexAsync(path, cancellationToken));
_verificationCache = new PreparedAssetVerificationCache(DataDirectory);
}
public string DataDirectory => Path.GetFullPath(_paths.DataDirectory);
@ -66,20 +68,28 @@ public sealed class LauncherInstallRecordStore
public static string GetBackupPath(string preparedAssetPath) =>
preparedAssetPath + ".previous-install";
/// <param name="forceFullVerification">Hash the package even when a
/// previous full hash of the same bytes is remembered. Install, update,
/// and any explicit "verify my files" request pass true; ordinary startup
/// passes false.</param>
public async Task<InstallRecordVerification> LoadAndVerifyAsync(
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
await using InstallerTransactionLease lease =
await InstallerTransactionLease.AcquireAsync(
DataDirectory,
cancellationToken)
.ConfigureAwait(false);
return await LoadAndVerifyUnderLeaseAsync(cancellationToken)
return await LoadAndVerifyUnderLeaseAsync(
cancellationToken,
forceFullVerification)
.ConfigureAwait(false);
}
internal async Task<InstallRecordVerification> LoadAndVerifyUnderLeaseAsync(
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
if (!File.Exists(RecordPath))
{
@ -143,7 +153,8 @@ public sealed class LauncherInstallRecordStore
FileVerification current = await VerifyFileAsync(
record.PreparedAssetPath,
record,
cancellationToken)
cancellationToken,
allowCachedResult: !forceFullVerification)
.ConfigureAwait(false);
if (current.IsValid)
{
@ -154,10 +165,15 @@ public sealed class LauncherInstallRecordStore
// A process crash may occur after the old verified package was moved
// aside but before the replacement record was published. Verify the
// backup against the still-current record before restoring it.
// The backup is a RECOVERY path: it runs only because the live package
// just failed, and it decides whether to move a file into that
// package's place. It always hashes — a cache entry describes the live
// package, never this one.
FileVerification backup = await VerifyFileAsync(
backupPath,
record,
cancellationToken)
cancellationToken,
allowCachedResult: false)
.ConfigureAwait(false);
if (backup.IsValid)
{
@ -387,10 +403,23 @@ public sealed class LauncherInstallRecordStore
};
}
/// <param name="allowCachedResult">
/// When true, a matching <see cref="PreparedAssetVerificationCache"/> entry
/// stands in for the hash. Hashing the prepared package is proportional to
/// its size, and it is very large — 27.9 GiB and 24.1 s on the machine this
/// was measured on — so doing it on every ordinary startup made the
/// launcher take half a minute to appear while re-confirming a fact that
/// had not changed. The cheap facts (size, last-write time) still run
/// unconditionally; only the hash is skipped, and only when a previous full
/// hash of that same file agreed with this record.
/// Pass false for the install/update paths and for any explicit
/// verify-my-files request, which must always hash.
/// </param>
private async Task<FileVerification> VerifyFileAsync(
string path,
LauncherInstallRecord record,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool allowCachedResult)
{
if (!File.Exists(path))
{
@ -399,7 +428,8 @@ public sealed class LauncherInstallRecordStore
try
{
long length = new FileInfo(path).Length;
var file = new FileInfo(path);
long length = file.Length;
if (length != record.PreparedAssetSize)
{
return new FileVerification(
@ -408,13 +438,42 @@ public sealed class LauncherInstallRecordStore
+ $"{record.PreparedAssetSize}, found {length}).");
}
DateTime lastWriteUtc = file.LastWriteTimeUtc;
if (allowCachedResult
&& PreparedAssetVerificationCache.Satisfies(
_verificationCache.TryRead(),
path,
length,
lastWriteUtc,
record.PreparedAssetSha256))
{
return new FileVerification(true, "Client content verified.");
}
string sha256 = await _computeSha256(path, cancellationToken)
.ConfigureAwait(false);
return FileIntegrity.Matches(sha256, record.PreparedAssetSha256)
? new FileVerification(true, "Client content verified.")
: new FileVerification(
if (!FileIntegrity.Matches(sha256, record.PreparedAssetSha256))
{
// The remembered fact, if any, is now known to be wrong about
// this file. Drop it rather than leaving a stale "verified"
// entry that a later startup could believe.
_verificationCache.Invalidate();
return new FileVerification(
false,
"The prepared package SHA-256 does not match the install record.");
}
// Re-read the write time: a concurrent writer between the stat
// above and the end of a multi-second hash would otherwise be
// remembered under the OLD timestamp, and the next startup would
// trust the cache for a file the hash never actually covered.
DateTime hashedWriteUtc = new FileInfo(path).LastWriteTimeUtc;
if (hashedWriteUtc == lastWriteUtc)
{
_verificationCache.Write(path, length, lastWriteUtc, sha256);
}
return new FileVerification(true, "Client content verified.");
}
catch (OperationCanceledException)
{

View file

@ -52,8 +52,14 @@ public interface ILauncherInstaller
DatDirectoryValidation ValidateDatDirectory(string? directory);
/// <param name="forceFullVerification">Hash the installed package even
/// when a previous full hash of the same bytes is remembered. Ordinary
/// startup passes false so the launcher window is not held behind a
/// multi-second hash of a very large file; an explicit "verify my files"
/// request passes true.</param>
Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default);
CancellationToken cancellationToken = default,
bool forceFullVerification = false);
Task<LauncherInstallResult> InstallAsync(
string datDirectory,
@ -115,7 +121,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
_datDirectories.Validate(directory);
public async Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default)
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
await _installGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
@ -128,7 +135,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
.ConfigureAwait(false);
InstallRecordVerification verification =
await RecoverExistingUnderPublicationGuardAsync(
cancellationToken)
cancellationToken,
forceFullVerification)
.ConfigureAwait(false);
_verifiedRecord = verification.Record;
return verification;
@ -204,7 +212,13 @@ public sealed class LauncherInstaller : ILauncherInstaller
string outputPath = _recordStore.PreparedAssetPath;
string backupPath = LauncherInstallRecordStore.GetBackupPath(outputPath);
InstallRecordVerification existing =
await RecoverExistingUnderPublicationGuardAsync(cancellationToken)
await RecoverExistingUnderPublicationGuardAsync(
cancellationToken,
// An install is about to replace this package, and this
// call decides whether the PRIOR one can be recovered.
// That decision must rest on real bytes, never on a
// remembered digest.
forceFullVerification: true)
.ConfigureAwait(false);
_verifiedRecord = existing.Record;
@ -441,7 +455,8 @@ public sealed class LauncherInstaller : ILauncherInstaller
private async Task<InstallRecordVerification>
RecoverExistingUnderPublicationGuardAsync(
CancellationToken cancellationToken)
CancellationToken cancellationToken,
bool forceFullVerification = false)
{
string outputPath = _recordStore.PreparedAssetPath;
await using BakePublicationGuardContract.PublicationLease publication =
@ -455,7 +470,9 @@ public sealed class LauncherInstaller : ILauncherInstaller
// promotion before recovery reaches this invalidation point.
BakePublicationGuardContract.Invalidate(outputPath, publication);
BakeOutputStagingContract.DeleteOwnedStagingFiles(outputPath);
return await _recordStore.LoadAndVerifyUnderLeaseAsync(cancellationToken)
return await _recordStore.LoadAndVerifyUnderLeaseAsync(
cancellationToken,
forceFullVerification)
.ConfigureAwait(false);
}

View file

@ -0,0 +1,189 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.Launcher.Core.Installation;
/// <summary>
/// One remembered "this exact file already hashed to this digest" fact.
/// <see cref="Sha256"/> is stored so the entry can be matched against the
/// install record's own digest — an entry that agrees with the file on disk
/// but disagrees with the record must never satisfy verification.
/// </summary>
internal sealed record PreparedAssetVerificationEntry
{
public required int Version { get; init; }
public required string Path { get; init; }
public required long Size { get; init; }
public required long LastWriteUtcTicks { get; init; }
public required string Sha256 { get; init; }
}
/// <summary>
/// Remembers the result of a full package hash so ordinary startup does not
/// have to repeat it.
///
/// <para><b>Why this is a sidecar and not a field on the install record.</b>
/// <see cref="LauncherInstallRecordStore"/> reads <c>install.json</c> with
/// <see cref="JsonUnmappedMemberHandling.Disallow"/>, so adding a property
/// there would make an OLDER launcher build reject the record outright and
/// demand a fresh ~28 GB bake after a rollback. An unknown sidecar file is
/// simply ignored by builds that predate it, which keeps the change
/// compatible in both directions — and a launcher that ignores the cache
/// merely hashes, which is the behavior that existed before.</para>
///
/// <para><b>This type never throws.</b> It is an optimization sitting in
/// front of a guarantee; a cache that could fail would turn a missing or
/// corrupt scratch file into a failed launch. Reads return null on anything
/// unexpected and writes swallow IO failures, so every failure mode
/// degrades to "hash it again".</para>
/// </summary>
internal sealed class PreparedAssetVerificationCache
{
internal const int CurrentVersion = 1;
private static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
};
private readonly string _path;
public PreparedAssetVerificationCache(string dataDirectory)
{
ArgumentException.ThrowIfNullOrWhiteSpace(dataDirectory);
_path = System.IO.Path.Combine(
System.IO.Path.GetFullPath(dataDirectory),
"install.verification.json");
}
public string CachePath => _path;
/// <summary>
/// The remembered entry, or null when there is none, it cannot be read,
/// it was written by a schema this build does not know, or it is
/// internally incomplete.
/// </summary>
public PreparedAssetVerificationEntry? TryRead()
{
try
{
if (!File.Exists(_path))
{
return null;
}
using FileStream stream = File.OpenRead(_path);
PreparedAssetVerificationEntry? entry =
JsonSerializer.Deserialize<PreparedAssetVerificationEntry>(
stream,
SerializerOptions);
if (entry is null
|| entry.Version != CurrentVersion
|| string.IsNullOrWhiteSpace(entry.Path)
|| string.IsNullOrWhiteSpace(entry.Sha256)
|| entry.Size <= 0)
{
return null;
}
return entry;
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or JsonException
or NotSupportedException
or ArgumentException)
{
return null;
}
}
/// <summary>
/// Records that <paramref name="path"/>, at this size and write time,
/// hashed to <paramref name="sha256"/>. Written through a temporary file
/// so an interrupted write cannot leave a half-parsed entry behind.
/// </summary>
public void Write(string path, long size, DateTime lastWriteUtc, string sha256)
{
var entry = new PreparedAssetVerificationEntry
{
Version = CurrentVersion,
Path = System.IO.Path.GetFullPath(path),
Size = size,
LastWriteUtcTicks = lastWriteUtc.Ticks,
Sha256 = sha256,
};
string temporaryPath = _path + ".tmp";
try
{
string? directory = System.IO.Path.GetDirectoryName(_path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(
temporaryPath,
JsonSerializer.Serialize(entry, SerializerOptions));
File.Move(temporaryPath, _path, overwrite: true);
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or NotSupportedException
or ArgumentException)
{
TryDelete(temporaryPath);
}
}
/// <summary>Forgets the remembered entry. Called when a full hash
/// disagrees with the install record, so a stale "verified" fact can
/// never outlive the evidence that produced it.</summary>
public void Invalidate() => TryDelete(_path);
/// <summary>
/// True when this entry can stand in for a full hash of the file
/// currently on disk: same file, same size, same write time, and a digest
/// that still agrees with the install record.
/// </summary>
public static bool Satisfies(
PreparedAssetVerificationEntry? entry,
string path,
long size,
DateTime lastWriteUtc,
string recordedSha256) =>
entry is not null
&& string.Equals(
entry.Path,
System.IO.Path.GetFullPath(path),
OperatingSystem.IsWindows()
? StringComparison.OrdinalIgnoreCase
: StringComparison.Ordinal)
&& entry.Size == size
&& entry.LastWriteUtcTicks == lastWriteUtc.Ticks
&& Integrity.FileIntegrity.Matches(entry.Sha256, recordedSha256);
private static void TryDelete(string path)
{
try
{
if (File.Exists(path))
{
File.Delete(path);
}
}
catch (Exception ex) when (ex is IOException
or UnauthorizedAccessException
or NotSupportedException
or ArgumentException)
{
// Best effort by contract — see the type doc.
}
}
}

View file

@ -44,6 +44,9 @@
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="8">
<Button Content="First-run setup"
Command="{Binding FirstRunWizardShell.OpenCommand}" />
<Button Content="Verify files"
AutomationProperties.Name="Verify installed client content"
Command="{Binding VerifyContentCommand}" />
<Button Content="Check for updates"
Command="{Binding UpdatePrompt.OpenCommand}" />
</StackPanel>

View file

@ -367,7 +367,8 @@ internal sealed class UnavailableLauncherInstaller : ILauncherInstaller
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
CancellationToken cancellationToken = default,
bool forceFullVerification = false) =>
Task.FromResult(new InstallRecordVerification(
InstallRecordVerificationState.Missing,
null,

View file

@ -12,6 +12,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
{
private readonly ILauncherOrchestrator _orchestrator;
private readonly IUiDispatcher _dispatcher;
private readonly ILauncherInstaller _installer;
private LauncherStateSnapshot? _snapshot;
private LauncherTreeNodeViewModel? _selectedNode;
private CancellationTokenSource? _operationCancellation;
@ -34,8 +35,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
_orchestrator.StateChanged += OnOrchestratorStateChanged;
EditorDialog = new ProfileEditorDialogViewModel();
_installer = installer ?? new UnavailableLauncherInstaller();
FirstRunWizardShell = new FirstRunInstallerViewModel(
installer ?? new UnavailableLauncherInstaller(),
_installer,
dispatcher,
OnInstallCompleted,
() => CanInteract,
@ -80,6 +82,9 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
ClearFinishedSessionsCommand = new RelayCommand(
_orchestrator.ClearFinishedSessions,
() => Sessions.Any(session => !session.IsActive) && CanInteract);
VerifyContentCommand = new AsyncRelayCommand(
VerifyContentAsync,
() => CanInteract && Sessions.All(session => !session.IsActive));
}
public ObservableCollection<LauncherTreeNodeViewModel> Servers { get; } = [];
@ -284,6 +289,10 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
public AsyncRelayCommand LaunchHeadlessCommand { get; }
/// <summary>LU1's explicit "verify files" action — see
/// <see cref="VerifyContentAsync"/>.</summary>
public AsyncRelayCommand VerifyContentCommand { get; }
public RelayCommand CancelOperationCommand { get; }
public RelayCommand ClearFinishedSessionsCommand { get; }
@ -972,6 +981,58 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
: message;
}
/// <summary>
/// LU1: the deliberate, user-asked-for full hash of the installed package.
/// Ordinary startup deliberately does NOT do this (it trusts a remembered
/// digest for a file whose size and write time are unchanged), so this is
/// the way to make the launcher actually re-read all ~28 GB and prove the
/// content is intact — the same shape as a game client's "verify files".
///
/// <para>A failed verification clears the install record, which disables
/// launching. That is the point: the launcher must not start a client
/// against content it just proved is wrong.</para>
/// </summary>
private async Task VerifyContentAsync()
{
if (IsBusy)
{
return;
}
using var cancellation = new CancellationTokenSource();
_operationCancellation = cancellation;
IsBusy = true;
LastError = null;
OperationStatus = "Verifying client content… this reads the whole package.";
try
{
InstallRecordVerification verification = await _installer
.LoadExistingAsync(cancellation.Token, forceFullVerification: true)
.ConfigureAwait(true);
_orchestrator.SetInstallRecord(verification.Record);
OperationStatus = verification.Status;
if (!verification.IsVerified)
{
LastError = verification.Status;
}
}
catch (OperationCanceledException)
{
OperationStatus = "Verification cancelled.";
}
catch (Exception ex)
{
LastError = SafeDisplayError(ex, secret: null);
OperationStatus = "Verification failed.";
}
finally
{
_operationCancellation = null;
IsBusy = false;
RefreshFromCore();
}
}
private void OnInstallCompleted(LauncherInstallRecord record)
{
_orchestrator.SetInstallRecord(record);
@ -1002,6 +1063,7 @@ public sealed class LauncherWindowViewModel : ObservableObject, IDisposable
LaunchHeadlessCommand.NotifyCanExecuteChanged();
CancelOperationCommand.NotifyCanExecuteChanged();
ClearFinishedSessionsCommand.NotifyCanExecuteChanged();
VerifyContentCommand.NotifyCanExecuteChanged();
foreach (LauncherSessionRowViewModel session in Sessions)
{
session.NotifyCommandState();

View file

@ -0,0 +1,223 @@
using AcDream.Launcher.Core.Installation;
using AcDream.Launcher.Core.Integrity;
using AcDream.Launcher.Core.Launching;
using AcDream.Platform;
namespace AcDream.Launcher.Core.Tests.Installation;
/// <summary>
/// LU1. Ordinary startup used to SHA-256 the whole installed package before
/// the launcher window was constructed. That package is ~28 GiB in practice
/// and the hash measured 24.1 s, so the launcher took roughly half a minute
/// to appear while re-confirming a digest that had not changed since the
/// install wrote it.
///
/// <para>These tests pin the replacement: the cheap facts (size, last-write
/// time) still run on every startup, the hash is skipped only when a previous
/// full hash of that same file agreed with the install record, and every way
/// that agreement can be false falls back to hashing.</para>
/// </summary>
[Collection(WorkingDirectoryCollection.Name)]
public sealed class PreparedAssetVerificationCacheTests : IDisposable
{
private const string PackageContent = "verified package";
private readonly string _root = Path.Combine(
Path.GetTempPath(),
"acdream-verification-cache-tests",
Guid.NewGuid().ToString("N"));
private readonly ApplicationPathSet _paths;
private readonly string _dats;
private int _hashCount;
public PreparedAssetVerificationCacheTests()
{
_paths = new ApplicationPathSet(
Path.Combine(_root, "config"),
Path.Combine(_root, "data"),
Path.Combine(_root, "cache"),
null);
_dats = Path.Combine(_root, "retail-dats");
Directory.CreateDirectory(_dats);
foreach (string fileName in DatDirectoryLocator.RequiredFileNames)
{
File.WriteAllText(Path.Combine(_dats, fileName), "fixture");
}
}
public void Dispose()
{
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
[Fact]
public async Task SecondStartupSkipsTheHashEntirely()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// The whole point of the slice: nothing is read from the package the
// second time around.
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
}
[Fact]
public async Task ForcedFullVerificationHashesEvenWithAValidCache()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
Assert.True((await store.LoadAndVerifyAsync(forceFullVerification: true))
.IsVerified);
Assert.Equal(2, _hashCount);
}
[Fact]
public async Task ATouchedPackageIsHashedAgain()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// Same bytes, new write time: the cheap facts no longer match, so the
// expensive one has to run. It still verifies, because the content is
// in fact unchanged.
File.SetLastWriteTimeUtc(
store.PreparedAssetPath,
File.GetLastWriteTimeUtc(store.PreparedAssetPath).AddMinutes(5));
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(2, _hashCount);
}
[Fact]
public async Task ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
// Identical length so the size check cannot catch it, and a fresh
// write time so the cache cannot be believed.
await File.WriteAllTextAsync(
store.PreparedAssetPath,
new string('x', PackageContent.Length));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.False(verification.IsVerified);
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
Assert.False(File.Exists(CachePath));
}
[Fact]
public async Task AResizedPackageIsRejectedWithoutHashingIt()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
await File.WriteAllTextAsync(store.PreparedAssetPath, PackageContent + "!");
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.False(verification.IsVerified);
Assert.Contains("size changed", verification.Status, StringComparison.Ordinal);
Assert.Equal(1, _hashCount);
}
[Fact]
public async Task ACacheWhoseDigestDisagreesWithTheRecordIsIgnored()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// A cache entry that matches the FILE but not the RECORD must never
// satisfy verification — otherwise a stale entry could vouch for a
// package the current record does not describe.
await File.WriteAllTextAsync(
CachePath,
(await File.ReadAllTextAsync(CachePath)).Replace(
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
new string('a', 64),
StringComparison.OrdinalIgnoreCase));
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(2, _hashCount);
}
[Theory]
[InlineData("")]
[InlineData("{ not json")]
[InlineData("{\"version\":9999}")]
public async Task AnUnreadableCacheDegradesToHashingRatherThanFailing(string content)
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
await File.WriteAllTextAsync(CachePath, content);
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(2, _hashCount);
}
[Fact]
public async Task BackupRecoveryHashesTheBackupEvenWhenTheLiveCacheIsValid()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
Assert.Equal(1, _hashCount);
// Crash shape: the verified package was moved aside and what sits in
// its place is wrong. The cache still describes the ORIGINAL bytes, so
// if recovery trusted it the launcher would accept a bad package.
string backup = LauncherInstallRecordStore.GetBackupPath(store.PreparedAssetPath);
File.Move(store.PreparedAssetPath, backup);
await File.WriteAllTextAsync(
store.PreparedAssetPath,
new string('z', PackageContent.Length));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.True(verification.IsVerified);
Assert.Equal(PackageContent, await File.ReadAllTextAsync(store.PreparedAssetPath));
Assert.False(File.Exists(backup));
// Live package hashed (and failed), then the backup hashed.
Assert.Equal(3, _hashCount);
}
private string CachePath => Path.Combine(
Path.GetFullPath(_paths.DataDirectory),
"install.verification.json");
private async Task<LauncherInstallRecordStore> CreateInstalledStoreAsync()
{
var store = new LauncherInstallRecordStore(
_paths,
datDirectories: null,
computeSha256: (path, cancellationToken) =>
{
Interlocked.Increment(ref _hashCount);
return FileIntegrity.ComputeSha256HexAsync(path, cancellationToken);
});
Directory.CreateDirectory(Path.GetDirectoryName(store.PreparedAssetPath)!);
await File.WriteAllTextAsync(store.PreparedAssetPath, PackageContent);
var info = new FileInfo(store.PreparedAssetPath);
await store.SaveAtomicallyAsync(new LauncherInstallRecord(
Path.GetFullPath(_dats),
Path.GetFullPath(store.PreparedAssetPath),
await FileIntegrity.ComputeSha256HexAsync(store.PreparedAssetPath),
info.Length,
LauncherInstallRecordStore.CurrentBakeToolVersion));
return store;
}
}

View file

@ -338,6 +338,10 @@ public sealed class LauncherWindowViewModelTests
var installer = new FakeLauncherInstaller();
using var viewModel = CreateInitialized(orchestrator, installer);
// LU1: nothing has asked for a verification yet. Startup's own
// verification happens in the composition root, not here.
Assert.Empty(installer.LoadExistingCalls);
viewModel.FirstRunWizardShell.OpenCommand.Execute(null);
Assert.Equal(installer.DetectedDirectory, viewModel.FirstRunWizardShell.DatDirectory);
@ -433,6 +437,77 @@ public sealed class LauncherWindowViewModelTests
Assert.True(orchestrator.ClearCalled);
}
/// <summary>
/// LU1. Ordinary startup trusts a remembered digest so the window is not
/// held behind a multi-second hash of a ~28 GiB file; "Verify files" is
/// the deliberate way to make it read the whole package again, so it must
/// force the full hash rather than hit the same fast path.
/// </summary>
[Fact]
public async Task VerifyFilesForcesAFullHashAndPublishesTheResult()
{
// Verification is gated on no session running, same as install and
// update: a failed verification clears the install record, and doing
// that under a live client would be incoherent.
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var record = new LauncherInstallRecord(
"C:/dats",
"C:/data/pak/acdream.pak",
new string('a', 64),
4096,
4);
var installer = new FakeLauncherInstaller
{
NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.Verified,
record,
"Client content verified."),
};
using var viewModel = CreateInitialized(orchestrator, installer);
await viewModel.VerifyContentCommand.ExecuteAsync();
Assert.Equal([true], installer.LoadExistingCalls);
Assert.Same(record, orchestrator.InstalledRecord);
Assert.Equal("Client content verified.", viewModel.OperationStatus);
Assert.Null(viewModel.LastError);
}
/// <summary>
/// LU1. A package that fails its full verification must clear the install
/// record — the launcher may not start a client against content it just
/// proved is wrong — and must say why.
/// </summary>
[Fact]
public async Task VerifyFilesClearsTheInstallRecordWhenVerificationFails()
{
using var orchestrator = new FakeLauncherOrchestrator
{
Session = FakeLauncherOrchestrator.CreateSession(
LauncherActivityState.Exited,
"Exited cleanly."),
};
var installer = new FakeLauncherInstaller
{
NextVerification = new InstallRecordVerification(
InstallRecordVerificationState.Invalid,
null,
"The prepared package SHA-256 does not match the install record."),
};
using var viewModel = CreateInitialized(orchestrator, installer);
await viewModel.VerifyContentCommand.ExecuteAsync();
Assert.Null(orchestrator.InstalledRecord);
Assert.Contains("SHA-256", viewModel.OperationStatus, StringComparison.Ordinal);
Assert.Contains("SHA-256", viewModel.LastError!, StringComparison.Ordinal);
}
private static LauncherWindowViewModel CreateInitialized(
FakeLauncherOrchestrator orchestrator,
ILauncherInstaller? installer = null)
@ -730,12 +805,26 @@ public sealed class LauncherWindowViewModelTests
"The DAT directory is incomplete.",
DatDirectoryLocator.RequiredFileNames);
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default) =>
Task.FromResult(new InstallRecordVerification(
/// <summary>LU1: what the next <see cref="LoadExistingAsync"/> returns.
/// Defaults to "not installed", which is what every pre-existing test
/// in this file expects.</summary>
public InstallRecordVerification NextVerification { get; set; } =
new(
InstallRecordVerificationState.Missing,
null,
"Client content is not installed."));
"Client content is not installed.");
/// <summary>LU1: the <c>forceFullVerification</c> values this fake was
/// called with, oldest first.</summary>
public List<bool> LoadExistingCalls { get; } = [];
public Task<InstallRecordVerification> LoadExistingAsync(
CancellationToken cancellationToken = default,
bool forceFullVerification = false)
{
LoadExistingCalls.Add(forceFullVerification);
return Task.FromResult(NextVerification);
}
public Task<LauncherInstallResult> InstallAsync(
string datDirectory,