Phase 2: checksum audit (occ backup:verify)
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled

Repository::verify(): every blob a snapshot's files and database dump reference must be in a
freshly re-downloaded, re-authenticated index (never trusted from the local cache, same reasoning
as prune()) and have its pack still present on the location -- shallow, cheap, no blob content
read, catches a deleted pack, a tampered/rolled-back index, or a database manifest that went
missing (reported as a failure, not silently skipped). With $deep, each blob is also downloaded
and decrypted, same as loadBlob() already does on every real read (AEAD authentication tag,
content hash against its blob id), done here deliberately for everything in the snapshot rather
than waiting for it to come up during an actual restore. A blob referenced by more than one file
is only checked once; pack presence is cached per pack, not re-checked per blob.

occ backup:verify <target> <snapshot> [--deep], meant to run periodically (cron, a sample of
recent snapshots), per PLAN.md's "Verificatie: periodiek checksums controleren, steekproef-restore".
The lease covering the audit is refreshed roughly every half of its TTL while blobs are checked,
rather than relying on a single long-lived lease like the other new phase-2 commands do.

Verified against a real S3-backed 1.4 GiB / 603-blob snapshot (shallow: instant; --deep: ~28s,
fully decrypted) in addition to the new unit tests and the roundtrip.sh addition (shallow and deep
on a snapshot with real, varied file sizes, now covered on every CI matrix combination).

Local review (Codex) on the first pass found three real issues, all fixed: a missing database
manifest was silently skipped instead of reported; shallow verification trusted the (possibly
stale or unauthenticated) local index cache instead of re-checking the authenticated index on the
location; and the lease had no refresh, so a --deep audit of a very large snapshot could outrun
its TTL and run unprotected against a concurrent prune.
This commit is contained in:
2026-10-05 23:46:50 +02:00
parent f18b4f0862
commit 935c7b6a08
6 changed files with 288 additions and 0 deletions
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Crypto\KeyRing;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
class VerifyTest extends TestCase {
use TempDirTrait;
private LocalBackend $backend;
private KeyRing $keys;
private string $root;
private string $src;
protected function setUp(): void {
$this->root = $this->tempDir();
$this->backend = new LocalBackend($this->root);
$this->keys = KeyRing::generate();
$this->src = $this->tempDir();
file_put_contents("$this->src/a", random_bytes(100000));
file_put_contents("$this->src/b", random_bytes(100000));
}
private function open(): Repository {
return Repository::openWithKey($this->backend, $this->keys, null, null);
}
public function testShallowVerifyPassesOnAHealthyRepository(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$result = $this->open()->verify($s['snapshot']);
$this->assertSame(2, $result['filesChecked']);
$this->assertGreaterThanOrEqual(1, $result['blobsChecked']);
$this->assertSame(0, $result['bytesChecked']); // not downloaded without --deep
$this->assertSame([], $result['missing']);
$this->assertSame([], $result['failed']);
}
public function testDeepVerifyDownloadsAndCountsBytes(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$result = $this->open()->verify($s['snapshot'], deep: true);
$this->assertSame(200000, $result['bytesChecked']);
$this->assertSame([], $result['missing']);
$this->assertSame([], $result['failed']);
}
public function testShallowVerifyDetectsAMissingPack(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
foreach ($this->backend->list('packs') as $pack) {
$this->backend->delete($pack);
}
$result = $this->open()->verify($s['snapshot']);
$this->assertNotSame([], $result['missing']);
}
public function testVerifyFailsOnAMissingDatabaseManifest(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$repo->writeSnapshot('snap-no-db', ['time' => gmdate('c'), 'db' => 'db/does-not-exist'], []);
$result = $this->open()->verify('snap-no-db');
$this->assertSame(['db/does-not-exist'], $result['missing']);
}
public function testDeepVerifyDetectsATamperedPack(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$s = $repo->backupDirectory($this->src);
$packs = $this->backend->list('packs');
$this->assertNotEmpty($packs);
$path = $this->root . '/' . $packs[0];
$bytes = file_get_contents($path);
// Flip a byte well inside the file, away from any header, so decryption's own
// authentication tag catches it (this must fail loudly, not just "do nothing").
$bytes[(int)(strlen($bytes) / 2)] = chr(~ord($bytes[(int)(strlen($bytes) / 2)]) & 0xFF);
file_put_contents($path, $bytes);
$result = $this->open()->verify($s['snapshot'], deep: true);
$this->assertNotSame([], $result['failed']);
}
}