fix: state the verification cache's real limit instead of a claim that is false on ZFS
All checks were successful
CI / linux-portable (push) Successful in 2m56s
CI / windows-gate (push) Successful in 4m53s
CI / release (push) Successful in 2m5s

Run 174's Linux job failed on
ASilentlyCorruptedPackageOfTheSameSizeStillFailsAndDropsTheCache. It passed in
isolation on that same machine, and passed under full-suite load there too, so
it looked like a flake. It is not.

Measured on the runner:

    same-mtime collisions: 141 / 200
    fs type: zfs

Its /tmp is ZFS, whose timestamp granularity is coarse enough that a same-size
rewrite usually lands on the SAME last-write time. So the startup fast path —
size plus write time — cannot see that modification, and the test was right to
fail. LU1's commit message claimed "truncating or touching the package still
blocks launch"; on a coarse-timestamp filesystem the second half of that is
false. NTFS's 100 ns resolution is why it never showed on Windows.

Rather than relax the test until it passes, the contract is now stated as two
facts that are true everywhere instead of one that is not:

- A same-size corruption whose write time moves is caught at startup. The test
  moves the timestamp explicitly instead of trusting the clock, so it asserts
  the mechanism rather than the filesystem's resolution.
- A corruption preserving BOTH size and write time is NOT caught at startup and
  IS caught by a forced full verification — which is exactly what the
  launcher's Verify files button runs. New test, so the escape hatch is
  covered rather than merely mentioned.

PreparedAssetVerificationCache now documents the limit with the measurement, so
the next reader does not have to rediscover it from a red pipeline.

Verified on Windows (11 passed) and five consecutive runs on the ZFS runner
itself (11 passed each). Full solution 14,375 passed, 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-19 22:03:52 +02:00
parent d233538f2c
commit 7037681a1f
2 changed files with 57 additions and 2 deletions

View file

@ -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));
}
/// <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()
{