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()
{