Files
nextcloud-ng_backup/tests/Unit/Repository/RepositoryTest.php
T
dillard 532b23482e
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 / 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
Storage limit per location
S3 has no bucket quota, so NG Backup enforces one: before a pack is
uploaded, the stored size plus the pack must stay under the location's
limit, otherwise the backup stops with an error (and administrators are
notified through the failed run). Repacking during prune is exempt, as it
is how space is freed. occ backup:target:limit NAME [SIZE|none].

Also ignore credential files in the repository.
2026-10-02 14:23:25 +02:00

162 lines
5.8 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');
}
public function testStorageLimitStopsTheBackup(): void {
$repo = Repository::init($this->backend, 'pw');
$repo->setMaxBytes(6 * 1048576); // less than the test tree
try {
$repo->backupDirectory($this->src);
$this->fail('backup should hit the limit');
} catch (\OCA\NgBackup\Repository\QuotaExceededException) {
}
$this->assertLessThanOrEqual(6 * 1048576, $repo->storedBytes());
$this->assertSame([], $repo->listSnapshots(), 'no snapshot from an incomplete backup');
}
}