root = $this->tempDir(); $this->keys = KeyRing::generate(); $this->src = $this->tempDir(); for ($i = 0; $i < 12; $i++) { file_put_contents("$this->src/f$i", random_bytes(3 * 1048576)); } $repo = Repository::initWithKey(new LocalBackend($this->root), $this->keys, $this->keys->wrap('pw')); $this->snapshot = $repo->backupDirectory($this->src)['snapshot']; } public function testARestoreGoesOnAfterAPackReadFailedAndTheListingCameBackEmpty(): void { $backend = new FlakyBackend(new LocalBackend($this->root)); $repo = Repository::openWithKey($backend, $this->keys); $backend->failPackReads = 1; // the read of a pack fails once $backend->emptyIndexListings = 1; // and listing the index right after comes back empty $target = $this->tempDir(); $this->assertSame(12, $repo->restore($this->snapshot, $target)); $this->assertSame(self::hashTree($this->src), self::hashTree($target)); $this->assertSame(0, $backend->failPackReads, 'the failure happened'); $this->assertSame(0, $backend->emptyIndexListings, 'the empty listing was seen'); } public function testAPackThatStaysUnreadableIsStillReportedAsSuch(): void { $backend = new FlakyBackend(new LocalBackend($this->root)); $repo = Repository::openWithKey($backend, $this->keys); $backend->failPackReads = 1000; try { $repo->restore($this->snapshot, $this->tempDir()); $this->fail('the packs cannot be read'); } catch (BackendException $e) { $this->assertStringStartsWith('Not found: packs/', $e->getMessage()); } } public function testACheckWithAnEmptyListingIsAnErrorNotEveryBlobMissing(): void { $backend = new FlakyBackend(new LocalBackend($this->root)); $repo = Repository::openWithKey($backend, $this->keys); $backend->emptyIndexListings = 1; $this->expectException(BackendException::class); $this->expectExceptionMessage('listed no index files'); $repo->verify($this->snapshot); } public function testRefreshingFetchesOnlyIndexFilesItHasNotLoaded(): void { $backend = new FlakyBackend(new LocalBackend($this->root)); $repo = Repository::openWithKey($backend, $this->keys); $index = (new \ReflectionProperty($repo, 'index'))->getValue($repo); $index->downloaded = 0; $index->refresh(); $this->assertSame(0, $index->downloaded, 'every index file is loaded already, also those whose blobs are elsewhere too'); } public function testOpeningWithAnEmptyListingKeepsTheCachedIndex(): void { $cache = new class implements IIndexCache { public array $data = []; public function all(string $r): array { return $this->data[$r] ?? []; } public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; } public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } } public function putMany(string $r, array $packs): void { foreach ($packs as $p => $e) { $this->put($r, (string)$p, $e); } } public function replaceAll(string $r, array $packs): void { $this->data[$r] = []; $this->putMany($r, $packs); } }; $backend = new FlakyBackend(new LocalBackend($this->root)); Repository::openWithKey($backend, $this->keys, $cache); $cached = $cache->data; $this->assertNotSame([], $cached); $backend->emptyIndexListings = 1; try { Repository::openWithKey($backend, $this->keys, $cache); $this->fail('an empty listing of a repository with an index must not be taken as is'); } catch (BackendException $e) { $this->assertStringContainsString('listed no index files', $e->getMessage()); } $this->assertSame($cached, $cache->data, 'the cache is untouched'); } } /** A location whose pack reads fail, and whose index listings come back empty, a set number of times. */ final class FlakyBackend implements IBackend { use \OCA\NgBackup\Backend\GetsOneByOne; public int $failPackReads = 0; public int $emptyIndexListings = 0; public function __construct( private IBackend $inner, ) { } public function put(string $path, $stream): void { $this->inner->put($path, $stream); } public function get(string $path) { if (str_starts_with($path, 'packs/') && $this->failPackReads > 0) { $this->failPackReads--; throw new BackendException('Not found: ' . $path); } return $this->inner->get($path); } public function getRange(string $path, int $offset, int $length): string { if (str_starts_with($path, 'packs/') && $this->failPackReads > 0) { $this->failPackReads--; throw new BackendException('Not found: ' . $path); } return $this->inner->getRange($path, $offset, $length); } 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 { if ($dir === 'index' && $this->emptyIndexListings > 0) { $this->emptyIndexListings--; return []; } return $this->inner->listFiles($dir); } public function delete(string $path): void { $this->inner->delete($path); } }