using AcDream.Launcher.Core.Installation; using AcDream.Launcher.Core.Integrity; using AcDream.Launcher.Core.Launching; using AcDream.Platform; namespace AcDream.Launcher.Core.Tests.Installation; /// /// 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. /// /// 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. /// [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 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; } }