acdream/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs
Erik 7037681a1f
All checks were successful
CI / linux-portable (push) Successful in 2m56s
CI / windows-gate (push) Successful in 4m53s
CI / release (push) Successful in 2m5s
fix: state the verification cache's real limit instead of a claim that is false on ZFS
Run 174's Linux job failed on
ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache. It passed in
isolation on that same machine, and passed under full-suite load there too, so
it looked like a flake. It is not.

Measured on the runner:

    same-mtime collisions: 141 / 200
    fs type: zfs

Its /tmp is ZFS, whose timestamp granularity is coarse enough that a same-size
rewrite usually lands on the SAME last-write time. So the startup fast path —
size plus write time — cannot see that modification, and the test was right to
fail. LU1's commit message claimed "truncating or touching the package still
blocks launch"; on a coarse-timestamp filesystem the second half of that is
false. NTFS's 100 ns resolution is why it never showed on Windows.

Rather than relax the test until it passes, the contract is now stated as two
facts that are true everywhere instead of one that is not:

- A same-size corruption whose write time moves is caught at startup. The test
  moves the timestamp explicitly instead of trusting the clock, so it asserts
  the mechanism rather than the filesystem's resolution.
- A corruption preserving BOTH size and write time is NOT caught at startup and
  IS caught by a forced full verification — which is exactly what the
  launcher's Verify files button runs. New test, so the escape hatch is
  covered rather than merely mentioned.

PreparedAssetVerificationCache now documents the limit with the measurement, so
the next reader does not have to rediscover it from a red pipeline.

Verified on Windows (11 passed) and five consecutive runs on the ZFS runner
itself (11 passed each). Full solution 14,375 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 22:03:52 +02:00

267 lines
11 KiB
C#

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 only the write time can betray it. Moved
// explicitly rather than trusting the clock: see the companion test
// below for why "the write just happened" is not the same as "the
// write time changed".
DateTime before = File.GetLastWriteTimeUtc(store.PreparedAssetPath);
await File.WriteAllTextAsync(
store.PreparedAssetPath,
new string('x', PackageContent.Length));
File.SetLastWriteTimeUtc(store.PreparedAssetPath, before.AddSeconds(1));
InstallRecordVerification verification = await store.LoadAndVerifyAsync();
Assert.False(verification.IsVerified);
Assert.Contains("SHA-256", verification.Status, StringComparison.Ordinal);
Assert.False(File.Exists(CachePath));
}
/// <summary>
/// The honest limit of a size + write-time check, and why "Verify files"
/// exists.
///
/// <para>A modification that preserves BOTH the size and the write time is
/// invisible to the startup check. That is not a theoretical hole: the
/// Linux CI runner's /tmp is ZFS, whose timestamp granularity is coarse
/// enough that 141 of 200 measured same-size rewrites produced an
/// identical mtime. This test originally asserted that startup catches
/// such a change, and it correctly failed there.</para>
///
/// <para>So the contract is stated as two facts instead of one wrong one:
/// startup does not catch it, and a forced full verification does. The
/// launcher's Verify files button is that forced verification.</para>
/// </summary>
[Fact]
public async Task CorruptionPreservingSizeAndWriteTimeIsCaughtOnlyByFullVerification()
{
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
DateTime original = File.GetLastWriteTimeUtc(store.PreparedAssetPath);
await File.WriteAllTextAsync(
store.PreparedAssetPath,
new string('x', PackageContent.Length));
File.SetLastWriteTimeUtc(store.PreparedAssetPath, original);
// Startup trusts the remembered digest — nothing cheap can tell the
// difference, and reading 28 GiB on every launch is the cost this
// whole mechanism exists to avoid.
Assert.True((await store.LoadAndVerifyAsync()).IsVerified);
InstallRecordVerification forced =
await store.LoadAndVerifyAsync(forceFullVerification: true);
Assert.False(forced.IsVerified);
Assert.Contains("SHA-256", forced.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;
}
}