diff --git a/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs b/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs index 1ca28178..d87dffa9 100644 --- a/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs +++ b/src/AcDream.Launcher.Core/Installation/PreparedAssetVerificationCache.cs @@ -40,6 +40,17 @@ internal sealed record PreparedAssetVerificationEntry /// 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". +/// +/// What this does NOT detect. A modification that preserves both +/// the size and the last-write time is invisible here. That is not a +/// theoretical hole — filesystem timestamp granularity varies, and on the +/// Linux CI runner's ZFS /tmp, 141 of 200 measured same-size rewrites +/// produced an identical mtime. Every ordinary corruption (a truncated +/// download, a partial write, a disk error, any normal editor) changes one or +/// both, and the launcher's Verify files action forces a full hash for +/// the case where a user wants certainty rather than a cheap check. +/// CorruptionPreservingSizeAndWriteTimeIsCaughtOnlyByFullVerification +/// pins both halves of that contract. /// internal sealed class PreparedAssetVerificationCache { diff --git a/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs b/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs index 4faae0f3..1855af20 100644 --- a/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs +++ b/tests/AcDream.Launcher.Core.Tests/Installation/PreparedAssetVerificationCacheTests.cs @@ -103,11 +103,15 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable 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. + // 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(); @@ -116,6 +120,46 @@ public sealed class PreparedAssetVerificationCacheTests : IDisposable Assert.False(File.Exists(CachePath)); } + /// + /// The honest limit of a size + write-time check, and why "Verify files" + /// exists. + /// + /// 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. + /// + /// 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. + /// + [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() {