diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs index f88dba2f..d2843964 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstallRecordStore.cs @@ -41,6 +41,7 @@ public sealed class LauncherInstallRecordStore private readonly ApplicationPathSet _paths; private readonly DatDirectoryLocator _datDirectories; private readonly Func> _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"; + /// 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. public async Task 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 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 }; } + /// + /// When true, a matching 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. + /// private async Task 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) { diff --git a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs index 4c8d4d0d..4deae741 100644 --- a/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs +++ b/src/AcDream.Launcher.Core/Installation/LauncherInstaller.cs @@ -52,8 +52,14 @@ public interface ILauncherInstaller DatDirectoryValidation ValidateDatDirectory(string? directory); + /// 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. Task LoadExistingAsync( - CancellationToken cancellationToken = default); + CancellationToken cancellationToken = default, + bool forceFullVerification = false); Task InstallAsync( string datDirectory, @@ -115,7 +121,8 @@ public sealed class LauncherInstaller : ILauncherInstaller _datDirectories.Validate(directory); public async Task 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 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); } diff --git a/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs b/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs new file mode 100644 index 00000000..1ca28178 --- /dev/null +++ b/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs @@ -0,0 +1,189 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AcDream.Launcher.Core.Installation; + +/// +/// One remembered "this exact file already hashed to this digest" fact. +/// 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. +/// +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; } +} + +/// +/// Remembers the result of a full package hash so ordinary startup does not +/// have to repeat it. +/// +/// Why this is a sidecar and not a field on the install record. +/// reads install.json with +/// , 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. +/// +/// This type never throws. 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". +/// +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; + + /// + /// 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. + /// + public PreparedAssetVerificationEntry? TryRead() + { + try + { + if (!File.Exists(_path)) + { + return null; + } + + using FileStream stream = File.OpenRead(_path); + PreparedAssetVerificationEntry? entry = + JsonSerializer.Deserialize( + 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; + } + } + + /// + /// Records that , at this size and write time, + /// hashed to . Written through a temporary file + /// so an interrupted write cannot leave a half-parsed entry behind. + /// + 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); + } + } + + /// 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. + public void Invalidate() => TryDelete(_path); + + /// + /// 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. + /// + 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. + } + } +} diff --git a/src/AcDream.Launcher/MainWindow.axaml b/src/AcDream.Launcher/MainWindow.axaml index 2f0a8515..9580ac42 100644 --- a/src/AcDream.Launcher/MainWindow.axaml +++ b/src/AcDream.Launcher/MainWindow.axaml @@ -44,6 +44,9 @@