0f4a238414
Storage and repository:
- Listing skipped every name containing ".part-", directories too, so the
exports of a user like "john.part-time" were invisible to prune on S3,
SFTP, FTP and SMB and their data was deleted. Only real temporary uploads
("<name>.part-<8 hex>") are skipped now.
- prune refuses to run while a recorded per-user export's manifest is
missing, instead of freeing that export's data.
- forget() and untrash() copy first, switch the catalog, and delete the
originals last, so a crash can no longer leave the catalog pointing at a
missing snapshot or database dump.
- Writes are checked against the expected size, so a short upload is an
error instead of a truncated pack; raw restores check rename() and touch().
- A catalog write refuses to overwrite a generation another process just
wrote, and the catalog lock lasts 300 s, so opening a large repository
inside it no longer lets the lease run out.
Backup runs and scheduling:
- A file that keeps changing is read again at most three times; then the
last read is kept and reported, instead of restarting forever and
blocking cleanup.
- Unreadable files and folders are recorded, logged, shown by occ
backup:run, stored in the snapshot and notified to admins, instead of
being skipped silently.
- After a failed scheduled backup, at most three attempts a day, an hour
apart, instead of a new run and notification every five minutes.
- The job no longer spins on a run whose lease another process holds, and
the weekly cleanup catches up after a Sunday spent backing up.
- Notifications for the per-user export retention events.
107 lines
4.1 KiB
PHP
107 lines
4.1 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
|
|
|
use OCA\NgBackup\Backend\LocalBackend;
|
|
use OCA\NgBackup\Crypto\KeyRing;
|
|
use OCA\NgBackup\Job\BackupRun;
|
|
use OCA\NgBackup\Repository\Repository;
|
|
use OCA\NgBackup\Repository\RepositoryException;
|
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/** Data-safety cases found in the pre-beta review. */
|
|
class CrashSafetyTest extends TestCase {
|
|
use TempDirTrait;
|
|
|
|
public function testListingKeepsNamesThatOnlyLookLikeTemporaryFiles(): void {
|
|
$root = $this->tempDir();
|
|
$backend = new LocalBackend($root);
|
|
mkdir("$root/users/john.part-time", 0700, true);
|
|
file_put_contents("$root/users/john.part-time/20261006T120000Z-aaaa0001", 'x');
|
|
file_put_contents("$root/users/upload.part-deadbeef", 'partial');
|
|
$this->assertSame(['users/john.part-time/20261006T120000Z-aaaa0001'], $backend->list('users'));
|
|
}
|
|
|
|
public function testPruneStopsWhenARecordedExportManifestIsMissing(): void {
|
|
$root = $this->tempDir();
|
|
$backend = new LocalBackend($root);
|
|
$keys = KeyRing::generate();
|
|
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
|
$stats = [];
|
|
$blob = $repo->storeData(random_bytes(1000), $stats);
|
|
$repo->flushPacks();
|
|
$path = 'users/alice/20261006T120000Z-aaaa0001';
|
|
$repo->putObject($path, json_encode(['uid' => 'alice', 'entries' => ['f.txt' => ['t' => 'f', 'b' => [$blob], 's' => 1000]]]));
|
|
$repo->recordUserExport($path);
|
|
unlink("$root/$path");
|
|
|
|
$packsBefore = $backend->list('packs');
|
|
try {
|
|
Repository::openWithKey($backend, $keys)->prune();
|
|
$this->fail('prune must refuse while a recorded export is missing');
|
|
} catch (RepositoryException $e) {
|
|
$this->assertStringContainsString('missing on the location', $e->getMessage());
|
|
}
|
|
$this->assertSame($packsBefore, $backend->list('packs'), 'no pack was deleted');
|
|
}
|
|
|
|
public function testForgetAndUntrashKeepTheSnapshotRestorable(): void {
|
|
$src = $this->tempDir();
|
|
file_put_contents("$src/a.txt", 'hello');
|
|
$root = $this->tempDir();
|
|
$backend = new LocalBackend($root);
|
|
$keys = KeyRing::generate();
|
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
|
$state = BackupRun::start($src);
|
|
while ($state['phase'] !== 'commit') {
|
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 5);
|
|
}
|
|
Repository::openWithKey($backend, $keys)->recordSnapshot($state['snapshot']);
|
|
$id = $state['snapshot'];
|
|
|
|
Repository::openWithKey($backend, $keys)->forget($id, 'test');
|
|
$repo = Repository::openWithKey($backend, $keys);
|
|
$this->assertSame([], $repo->listSnapshots());
|
|
$this->assertArrayHasKey($id, $repo->trash());
|
|
$this->assertFalse($backend->exists('snapshots/' . $id), 'original removed after the catalog switch');
|
|
|
|
$repo->untrash($id);
|
|
$repo = Repository::openWithKey($backend, $keys);
|
|
$this->assertSame([$id], $repo->listSnapshots());
|
|
$this->assertSame([], $repo->trash());
|
|
$target = $this->tempDir();
|
|
$repo->restore($id, $target);
|
|
$this->assertSame('hello', file_get_contents("$target/a.txt"));
|
|
}
|
|
|
|
public function testFileThatKeepsChangingDoesNotStallTheRun(): void {
|
|
$src = $this->tempDir();
|
|
file_put_contents("$src/live.db", random_bytes(3 * Repository::BLOB_SIZE));
|
|
$root = $this->tempDir();
|
|
$backend = new LocalBackend($root);
|
|
$keys = KeyRing::generate();
|
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
|
|
|
$state = BackupRun::start($src);
|
|
$n = 0;
|
|
for ($i = 0; $i < 50 && $state['phase'] !== 'commit'; $i++) {
|
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.001);
|
|
// Rewrite the file after every step: it never stays the same between two steps.
|
|
$fh = fopen("$src/live.db", 'r+b');
|
|
fwrite($fh, random_bytes(16));
|
|
fclose($fh);
|
|
touch("$src/live.db", time() + ++$n);
|
|
clearstatcache();
|
|
}
|
|
$this->assertSame('commit', $state['phase'], 'the run finishes instead of retrying forever');
|
|
$this->assertSame(1, $state['stats']['changing'] ?? 0);
|
|
$this->assertSame(['live.db'], BackupRun::warnings($state)['changing']);
|
|
}
|
|
}
|