diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 2146e20..b23570a 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -895,8 +895,27 @@ final class Repository { } catch (BackendException) { throw $e; } + $pack = $moved; + } + try { + return $this->decodeBlob($id, $encrypted); + } catch (\Exception $damaged) { + // Bytes that do not decrypt: damaged on the location, or a read that went wrong on the + // way (a stream that lost its place: an SMB restore stopped after 17 hours like this). + // Read exactly this blob once more, on a request of its own; only a second failure + // means the data itself is damaged. + $this->packReader->close(); + try { + $again = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length); + } catch (\Exception) { + throw $damaged; + } + $data = $this->decodeBlob($id, $again); + if (function_exists('OCP\Log\logger')) { + \OCP\Log\logger('ng_backup')->warning('NG Backup: blob ' . $id . ' did not decrypt as first read from pack ' . $pack . '; read again, it is fine'); + } + return $data; } - return $this->decodeBlob($id, $encrypted); } /** Decrypts and checks a blob read from its pack; throws when it is corrupted or tampered with. */ diff --git a/tests/Unit/Repository/DamagedReadTest.php b/tests/Unit/Repository/DamagedReadTest.php new file mode 100644 index 0000000..d532496 --- /dev/null +++ b/tests/Unit/Repository/DamagedReadTest.php @@ -0,0 +1,116 @@ +root = $this->tempDir(); + $this->keys = KeyRing::generate(); + $this->src = $this->tempDir(); + for ($i = 0; $i < 6; $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 testABlobThatCameInDamagedOnceIsReadAgain(): void { + $backend = new GarblingBackend(new LocalBackend($this->root), streams: 1); + $target = $this->tempDir(); + + $this->assertSame(6, Repository::openWithKey($backend, $this->keys)->restore($this->snapshot, $target)); + + $this->assertSame(self::hashTree($this->src), self::hashTree($target)); + $this->assertSame(0, $backend->streams, 'the damaged read happened'); + } + + public function testDataDamagedOnTheLocationStillStopsTheRestore(): void { + $backend = new GarblingBackend(new LocalBackend($this->root), streams: 1000, ranges: 1000); + + $this->expectException(\Exception::class); + Repository::openWithKey($backend, $this->keys)->restore($this->snapshot, $this->tempDir()); + } +} + +/** Pack reads that flip a byte: the first $streams pack streams and $ranges range reads. */ +final class GarblingBackend implements IBackend { + use \OCA\NgBackup\Backend\GetsOneByOne; + + public function __construct( + private IBackend $inner, + public int $streams = 0, + public int $ranges = 0, + ) { + } + + public function put(string $path, $stream): void { + $this->inner->put($path, $stream); + } + + public function get(string $path) { + $fh = $this->inner->get($path); + if (!str_starts_with($path, 'packs/') || $this->streams <= 0) { + return $fh; + } + $this->streams--; + $data = stream_get_contents($fh); + fclose($fh); + for ($k = 100; $k < strlen($data); $k += 65536) { + $data[$k] = chr(ord($data[$k]) ^ 0x55); + } + $out = fopen('php://temp', 'w+b'); + fwrite($out, $data); + rewind($out); + return $out; + } + + public function getRange(string $path, int $offset, int $length): string { + $data = $this->inner->getRange($path, $offset, $length); + if (str_starts_with($path, 'packs/') && $this->ranges > 0) { + $this->ranges--; + $data[0] = chr(ord($data[0]) ^ 0x55); + } + return $data; + } + + 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); + } +}