Files
nextcloud-ng_backup/tests/Unit/Repository/RepositoryTest.php
T
dillard 8ef9dc0f35 Fixes from the phase-1 code review
- one locking protocol per location: backup steps and restores hold a
  shared lock, prune and untrash an exclusive one and refuse while any
  run (backup, restore, export) exists; starting a backup checks and
  creates the run under the exclusive lock, closing the race with prune
- snapshot entries are validated when read (relative path without '.',
  '..', NUL, backslash or control characters); raw restore also checks
  that the resolved directory stays below the target; the restore API
  and service accept canonical paths only
- recovery kit confirmation requires a recent password confirmation and
  the confirmation code printed in the current kit file (new code per
  kit version), proving the kit was actually downloaded
- prune decides on the authenticated index files on the location and
  refreshes the local index cache from them
- restore job marks the run failed on any error; restore runs without
  progress for 6 hours are failed so they cannot block cleanup forever
- tests for safe paths and a malicious snapshot path
2026-10-02 11:21:40 +02:00

150 lines
5.3 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Crypto\CryptoException;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Repository\RepositoryException;
use OCA\NgBackup\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
class RepositoryTest extends TestCase {
use TempDirTrait;
private string $src;
private LocalBackend $backend;
protected function setUp(): void {
$this->src = $this->tempDir();
$this->backend = new LocalBackend($this->tempDir());
for ($i = 0; $i < 30; $i++) {
$this->write("docs/d" . ($i % 3) . "/note-$i.txt", str_repeat("regel $i ", 500));
}
$this->write('bin/random.bin', random_bytes(9 * 1048576 + 123)); // spans 3 blobs
$this->write('copies/a.bin', $same = random_bytes(5 * 1048576));
$this->write('copies/b.bin', $same);
$this->write('empty.txt', '');
}
private function write(string $rel, string $data): void {
@mkdir(dirname($this->src . '/' . $rel), 0700, true);
file_put_contents($this->src . '/' . $rel, $data);
}
public function testBackupAndRestoreAreIdentical(): void {
$repo = Repository::init($this->backend, 'pw');
$r = $repo->backupDirectory($this->src);
$this->assertSame(34, $r['files']);
$this->assertGreaterThanOrEqual(2, $r['dupBlobs'], 'identical copies are stored once');
$target = $this->tempDir();
Repository::open($this->backend, 'pw')->restore($r['snapshot'], $target);
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
}
public function testUnchangedRunReadsAndUploadsNothing(): void {
$repo = Repository::init($this->backend, 'pw');
$r1 = $repo->backupDirectory($this->src);
$r2 = $repo->backupDirectory($this->src, $r1['snapshot']);
$this->assertSame(0, $r2['read']);
$this->assertSame(0, $r2['uploaded']);
}
public function testChangeInLargeFileUploadsOnlyAffectedBlob(): void {
$repo = Repository::init($this->backend, 'pw');
$r1 = $repo->backupDirectory($this->src);
$fh = fopen($this->src . '/bin/random.bin', 'r+b');
fseek($fh, 5 * 1048576);
fwrite($fh, 'changed');
fclose($fh);
touch($this->src . '/bin/random.bin', time() + 10);
$r2 = $repo->backupDirectory($this->src, $r1['snapshot']);
$this->assertSame(1, $r2['read']);
$this->assertSame(1, $r2['newBlobs']);
}
public function testPartialRestore(): void {
$repo = Repository::init($this->backend, 'pw');
$r = $repo->backupDirectory($this->src);
$target = $this->tempDir();
$this->assertSame(10, $repo->restore($r['snapshot'], $target, 'docs/d1/'));
}
public function testWrongPassphrase(): void {
Repository::init($this->backend, 'pw');
$this->expectException(CryptoException::class);
Repository::open($this->backend, 'other');
}
public function testInitRefusesExistingRepository(): void {
Repository::init($this->backend, 'pw');
$this->expectException(RepositoryException::class);
Repository::init($this->backend, 'pw');
}
public function testTamperedPackIsDetected(): void {
$repo = Repository::init($this->backend, 'pw');
$r = $repo->backupDirectory($this->src);
$root = (new \ReflectionProperty(LocalBackend::class, 'root'))->getValue($this->backend);
$pack = $root . '/' . $this->backend->list('packs')[0];
$bytes = file_get_contents($pack);
$bytes[200] = chr(ord($bytes[200]) ^ 0xff);
file_put_contents($pack, $bytes);
$this->expectException(\RuntimeException::class);
$repo->restore($r['snapshot'], $this->tempDir());
}
public function testBlobWriterAndReadLinesAcrossBlobBoundaries(): void {
$repo = Repository::init($this->backend, 'pw');
$stats = [];
$writer = $repo->blobWriter($stats);
$lines = [];
for ($i = 0; $i < 20000; $i++) {
$lines[] = json_encode(['i' => $i, 'pad' => str_repeat('x', $i % 400)]);
}
$writer->write(implode("\n", $lines) . "\n");
$blobs = $writer->finish();
$repo->flushPacks();
$this->assertGreaterThan(1, count($blobs));
$this->assertSame($lines, iterator_to_array(Repository::open($this->backend, 'pw')->readLines($blobs), false));
}
public function testObjectsAreBoundToTheirPath(): void {
$repo = Repository::init($this->backend, 'pw');
$repo->putObject('db/1', 'one');
$root = (new \ReflectionProperty(LocalBackend::class, 'root'))->getValue($this->backend);
copy("$root/db/1", "$root/db/2");
$this->expectException(CryptoException::class);
$repo->getObject('db/2');
}
public function testSafePaths(): void {
foreach (['data/alice/files/a.txt', 'config/config.php', 'a b/c-d_e.f'] as $ok) {
$this->assertTrue(Repository::isSafePath($ok), $ok);
}
foreach (['', '/etc/passwd', '../x', 'a/../b', 'a/./b', 'a//b', 'a/', "a\0b", 'a\\b', "a\nb"] as $bad) {
$this->assertFalse(Repository::isSafePath($bad), json_encode($bad));
}
}
public function testMaliciousSnapshotPathIsRejectedOnRestore(): void {
$repo = Repository::init($this->backend, 'pw');
$stats = [];
$blob = $repo->storeData('evil', $stats);
$repo->flushPacks();
$repo->writeSnapshot('bad', ['time' => gmdate('c')], [json_encode(['p' => '../../escaped.txt', 's' => 4, 'm' => 0, 'b' => [$blob]])]);
$target = $this->tempDir() . '/inner';
mkdir($target);
try {
$repo->restore('bad', $target);
$this->fail('restore of a ../ path must fail');
} catch (RepositoryException) {
}
$this->assertFileDoesNotExist(dirname($target, 2) . '/escaped.txt');
$this->assertFileDoesNotExist(dirname($target) . '/escaped.txt');
}
}