029b3b9cc7
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 / psalm (push) Has been cancelled
CI / frontend (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
CI / integration-targets (push) Has been cancelled
CI / integration-user (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
The DR test of 1.0.2 over SMB stopped after 392 GiB of 983: the connection to the Storage Box dropped, and the smbclient extension then returns a string of length -1 for a failed read (it stores smbc_read()'s -1 in an unsigned length). The read loop counted that as data and went backwards forever at 100% CPU. Reads over SMB are now checked; a failed one is read again the usual way over a new connection. backup:restore:full that runs again (--force) leaves the data files already in place with the snapshot's size and modification time, as a backup takes them as unchanged, and reads ahead only what is still missing: after 392 GiB it goes on instead of starting over.
258 lines
10 KiB
PHP
258 lines
10 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 testCompressedDataSkipsDeflateAndOtherDataIsStillCompressed(): void {
|
|
$repo = Repository::init($this->backend, 'pw');
|
|
$text = str_repeat("a line of a log file, much like the one before\n", 30000); // ~1.4 MiB
|
|
$noise = random_bytes(1048576); // like a photo: deflate saves nothing
|
|
$stats = [];
|
|
$textId = $repo->storeData($text, $stats);
|
|
$textUploaded = $stats['uploaded'];
|
|
$noiseId = $repo->storeData($noise, $stats);
|
|
$repo->flushPacks();
|
|
$this->assertLessThan(strlen($text) / 10, $textUploaded);
|
|
$this->assertLessThan(strlen($noise) + 1024, $stats['uploaded'] - $textUploaded);
|
|
$this->assertSame($text . $noise, implode('', iterator_to_array(Repository::open($this->backend, 'pw')->readBlobs([$textId, $noiseId]), false)));
|
|
}
|
|
|
|
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 testRestoreOfOneFile(): void {
|
|
$repo = Repository::init($this->backend, 'pw');
|
|
$r = $repo->backupDirectory($this->src);
|
|
$target = $this->tempDir();
|
|
// occ backup:restore --to-directory passes the path with a "/" appended
|
|
$this->assertSame(1, $repo->restore($r['snapshot'], $target, 'docs/d1/note-1.txt/'));
|
|
$this->assertSame(file_get_contents($this->src . '/docs/d1/note-1.txt'), file_get_contents($target . '/docs/d1/note-1.txt'));
|
|
$this->assertSame(0, $repo->restore($r['snapshot'], $this->tempDir(), 'docs/d1/note-1/'), 'a prefix of a name is not that file');
|
|
|
|
$stripped = $this->tempDir();
|
|
$this->assertSame(1, $repo->restore($r['snapshot'], $stripped, 'docs/d1/note-1.txt/', true));
|
|
$this->assertFileExists($stripped . '/note-1.txt');
|
|
}
|
|
|
|
public function testARestoreThatRunsAgainKeepsWhatIsInPlaceAndRestoresTheRest(): void {
|
|
$location = $this->tempDir();
|
|
$backend = new LocalBackend($location);
|
|
$repo = Repository::init($backend, 'pw');
|
|
$r = $repo->backupDirectory($this->src);
|
|
$target = $this->tempDir();
|
|
$repo->restore($r['snapshot'], $target);
|
|
|
|
// as a stopped restore leaves it: one file not there yet, one with other content
|
|
unlink($target . '/docs/d1/note-1.txt');
|
|
file_put_contents($target . '/docs/d0/note-0.txt', 'something else');
|
|
$this->assertSame(34, $repo->restore($r['snapshot'], $target, keepUnchanged: true));
|
|
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
|
|
|
|
// all in place: nothing is read from the location, which could not give it anyway now
|
|
self::removeTree($location . '/packs');
|
|
$this->assertSame(34, Repository::open($backend, 'pw')->restore($r['snapshot'], $target, keepUnchanged: true));
|
|
$this->expectException(\Throwable::class);
|
|
Repository::open($backend, 'pw')->restore($r['snapshot'], $target);
|
|
}
|
|
|
|
private static function removeTree(string $dir): void {
|
|
foreach (new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::CHILD_FIRST) as $f) {
|
|
$f->isDir() ? rmdir($f->getPathname()) : unlink($f->getPathname());
|
|
}
|
|
rmdir($dir);
|
|
}
|
|
|
|
public function testRestoreRemovesTemporariesOfAStoppedRestore(): void {
|
|
$repo = Repository::init($this->backend, 'pw');
|
|
$r = $repo->backupDirectory($this->src);
|
|
$target = $this->tempDir();
|
|
mkdir($target . '/docs/d1', 0700, true);
|
|
mkdir($target . '/elsewhere', 0700, true);
|
|
$stale = [
|
|
$target . '/docs/d1/note-1.txt.ngb-restore-0123456789ab', // from a restore to a directory
|
|
$target . '/docs/d1/.note-4.txt.ngb-restore-a1b2c3', // from a restore through Nextcloud
|
|
];
|
|
$kept = [
|
|
$target . '/docs/d1/notes.ngb-restore-draft.txt', // not a temporary of ours
|
|
$target . '/elsewhere/x.ngb-restore-0123456789ab', // not a directory this restore writes to
|
|
];
|
|
foreach ([...$stale, ...$kept] as $f) {
|
|
file_put_contents($f, 'partial');
|
|
}
|
|
$repo->restore($r['snapshot'], $target, 'docs/');
|
|
foreach ($stale as $f) {
|
|
$this->assertFileDoesNotExist($f);
|
|
}
|
|
foreach ($kept as $f) {
|
|
$this->assertFileExists($f);
|
|
}
|
|
}
|
|
|
|
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 testInitRefusesALocationWithBackupDataButNoConfig(): void {
|
|
$repo = Repository::init($this->backend, 'pw');
|
|
$repo->backupDirectory($this->src);
|
|
$this->backend->delete('config'); // missing, or not seen for a moment
|
|
|
|
try {
|
|
Repository::init($this->backend, 'pw');
|
|
$this->fail('a new repository over existing backup data');
|
|
} catch (RepositoryException $e) {
|
|
$this->assertStringContainsString('holds backup data', $e->getMessage());
|
|
}
|
|
$this->assertFalse($this->backend->exists('config'), 'no new config written');
|
|
}
|
|
|
|
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');
|
|
}
|
|
}
|