294 lines
12 KiB
C#
294 lines
12 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 CacheMissReportsTheExceptionalLongReadBeforeHashing()
|
|
{
|
|
LauncherInstallRecordStore store = await CreateInstalledStoreAsync();
|
|
var statuses = new List<string>();
|
|
|
|
Assert.True((await store.LoadAndVerifyAsync(
|
|
progress: new ImmediateProgress(statuses.Add))).IsVerified);
|
|
|
|
Assert.Contains(
|
|
statuses,
|
|
status => status.Contains("whole world-data pak", StringComparison.Ordinal)
|
|
&& status.Contains("30 seconds", StringComparison.Ordinal));
|
|
}
|
|
|
|
[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.
|
|
DateTime verifiedWriteTime = File.GetLastWriteTimeUtc(store.PreparedAssetPath);
|
|
string backup = LauncherInstallRecordStore.GetBackupPath(store.PreparedAssetPath);
|
|
File.Move(store.PreparedAssetPath, backup);
|
|
await File.WriteAllTextAsync(
|
|
store.PreparedAssetPath,
|
|
new string('z', PackageContent.Length));
|
|
File.SetLastWriteTimeUtc(
|
|
store.PreparedAssetPath,
|
|
verifiedWriteTime.AddMinutes(1));
|
|
Assert.NotEqual(
|
|
verifiedWriteTime,
|
|
File.GetLastWriteTimeUtc(store.PreparedAssetPath));
|
|
|
|
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;
|
|
}
|
|
|
|
private sealed class ImmediateProgress(Action<string> report) : IProgress<string>
|
|
{
|
|
public void Report(string value) => report(value);
|
|
}
|
|
}
|