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 testVerifyReportsHowFarItIs(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $seen = []; $this->open()->verify($s['snapshot'], heartbeat: function (array $p) use (&$seen): void { $seen[] = $p; }); $phases = array_values(array_unique(array_column($seen, 'phase'))); $this->assertSame(['index', 'packs', 'files'], $phases); $this->assertSame(['phase' => 'packs', 'done' => 256, 'total' => 256], $seen[array_key_last(array_filter($seen, fn ($p) => $p['phase'] === 'packs'))]); $last = end($seen); $this->assertSame(2, $last['done']); $this->assertNull($last['total'], 'this snapshot has no file count in its metadata (a backup run records one)'); } public function testVerifyListsPackFoldersInsteadOfCheckingEveryPack(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $counting = new ListingBackend($this->backend); $result = Repository::openWithKey($counting, $this->keys, null, null)->verify($s['snapshot']); $this->assertSame([], $result['missing']); $this->assertSame(0, $counting->packExists); } public function testAPackFolderThatCannotBeListedIsCheckedPackByPack(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $counting = new ListingBackend($this->backend, unlistable: true); $result = Repository::openWithKey($counting, $this->keys, null, null)->verify($s['snapshot']); $this->assertSame([], $result['missing']); $this->assertGreaterThan(0, $counting->packExists); } public function testAnEmptyFolderListingIsNoProofThatItsPacksAreGone(): void { // A storage also reports an empty folder when it could not look (is_dir() false). $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $counting = new ListingBackend($this->backend, emptyListings: true); $result = Repository::openWithKey($counting, $this->keys, null, null)->verify($s['snapshot']); $this->assertSame([], $result['missing']); $this->assertGreaterThan(0, $counting->packExists); } public function testAPackMissingFromAFolderThatIsNotEmptyIsReportedWithoutAskingForIt(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $packs = $this->backend->list('packs'); // Another file in the same folder keeps its listing non-empty. $this->backend->put(dirname($packs[0]) . '/0000other', fopen('data://text/plain,x', 'r')); foreach ($packs as $pack) { $this->backend->delete($pack); } $counting = new ListingBackend($this->backend); $result = Repository::openWithKey($counting, $this->keys, null, null)->verify($s['snapshot']); $this->assertNotSame([], $result['missing']); $this->assertSame(0, $counting->packExists); } public function testAPackMissingFromAnEmptyFolderIsReportedToo(): 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 = Repository::openWithKey(new ListingBackend($this->backend), $this->keys, null, null)->verify($s['snapshot']); $this->assertNotSame([], $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']); } public function testDeepVerifyRetriesAReadThatFailsOnce(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $flaky = new FlakyRangeBackend($this->backend, failures: 1); $result = Repository::openWithKey($flaky, $this->keys, null, null)->verify($s['snapshot'], deep: true); $this->assertSame(200000, $result['bytesChecked']); $this->assertSame([], $result['failed']); $this->assertSame([], $result['unreadable']); } public function testDeepVerifyReportsAPersistentReadErrorAsUnreadableNotCorrupted(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $broken = new FlakyRangeBackend($this->backend, failures: PHP_INT_MAX); $result = Repository::openWithKey($broken, $this->keys, null, null)->verify($s['snapshot'], deep: true); $this->assertSame([], $result['failed']); $this->assertNotSame([], $result['unreadable']); $this->assertSame('connection reset', $result['readError']); } public function testDeepVerifyDoesNotHideAProgrammingError(): void { $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw')); $s = $repo->backupDirectory($this->src); $buggy = new FlakyRangeBackend($this->backend, failures: PHP_INT_MAX, error: new \TypeError('bug')); $this->expectException(\TypeError::class); Repository::openWithKey($buggy, $this->keys, null, null)->verify($s['snapshot'], deep: true); } } /** Delegates to a real backend, but reading a pack throws for the first $failures reads. */ final class FlakyRangeBackend implements IBackend { use \OCA\NgBackup\Backend\GetsOneByOne; public function __construct( private IBackend $inner, private int $failures, private ?\Throwable $error = null, ) { } public function put(string $path, $stream): void { $this->inner->put($path, $stream); } public function get(string $path) { if (str_starts_with($path, 'packs/')) { $this->fail(); } return $this->inner->get($path); } public function getRange(string $path, int $offset, int $length): string { $this->fail(); return $this->inner->getRange($path, $offset, $length); } private function fail(): void { if ($this->failures > 0) { $this->failures--; throw $this->error ?? new BackendException('connection reset'); } } public function exists(string $path): bool { return $this->inner->exists($path); } public function move(string $from, string $to): void { $this->inner->move($from, $to); } public function list(string $prefix): array { return $this->inner->list($prefix); } public function listFiles(string $dir): array { return $this->inner->listFiles($dir); } public function delete(string $path): void { $this->inner->delete($path); } } /** Delegates to a real backend; counts exists() on packs, and can refuse to list pack folders. */ final class ListingBackend implements IBackend { use \OCA\NgBackup\Backend\GetsOneByOne; public int $packExists = 0; public function __construct( private IBackend $inner, private bool $unlistable = false, private bool $emptyListings = false, ) { } public function put(string $path, $stream): void { $this->inner->put($path, $stream); } public function get(string $path) { return $this->inner->get($path); } public function getRange(string $path, int $offset, int $length): string { return $this->inner->getRange($path, $offset, $length); } public function exists(string $path): bool { if (str_starts_with($path, 'packs/')) { $this->packExists++; } return $this->inner->exists($path); } public function move(string $from, string $to): void { $this->inner->move($from, $to); } public function list(string $prefix): array { return $this->inner->list($prefix); } public function listFiles(string $dir): array { if ($this->unlistable && str_starts_with($dir, 'packs/')) { throw new BackendException('Cannot list ' . $dir); } if ($this->emptyListings && str_starts_with($dir, 'packs/')) { return []; } return $this->inner->listFiles($dir); } public function delete(string $path): void { $this->inner->delete($path); } }