Empty folders in backup and restore, a full disk stops a restore, progress in the Bin

- Record folders without files in the meta record of a snapshot and restore them (#27)
- Errors writing to the restore directory stop the restore, also with --salvage
- Resume: check the folder before skipping, clean temporaries in folders that are complete
- Clean only read-ahead folders of our own; refresh the lease on skipped files
- Drop tables a failed database restore created; keep --salvage from calling a restored database fresh
- Retry a pack that fails to open on the storage (SMB)
- Without cron, running backups take turns from the open page
- Bin: read in parts with a counter and progress bar, show read errors, refresh when a run ends
This commit is contained in:
2026-11-06 15:50:02 +01:00
parent ce40053cec
commit b14b16c66c
22 changed files with 693 additions and 63 deletions
+25
View File
@@ -11,6 +11,31 @@ Each heading starts with that `<version>`, once: the app store shows a release t
whose heading starts with its `x.y.z` and ignores anything after it, so `## [1.0.0-rc.6]` would be
shown under 1.0.0. The git tag of a release candidate goes after the date.
## [1.0.3] - Unreleased
### Fixed
- Empty folders are backed up and restored (#27), in `occ backup:restore`, `occ backup:restore:full`
and the restore page, where they are shown too. They come back from the first backup made with
this version; older restore points do not contain them. An older version of NG Backup can still
read new restore points, and then only leaves the empty folders out.
- A full disk during a restore no longer looks like a damaged backup. The restore now stops with a
message about the disk, also with `--salvage`, and does not point at `--salvage` any more. The same
goes for other trouble writing to the restore directory, such as permissions.
- A restore that goes on after a stop also removes the temporary files in folders where every file
is already in place, and checks that those folders are inside the restore directory before it
leaves their files alone.
- A restore only removes read-ahead folders of its own (`.ngb-restore-spool-` with 8 hex digits).
- A restore keeps its lock on the location during a long row of files that are already in place.
- A database restore that fails no longer leaves behind tables it created, which could stop the
installation of that app later. When only NG Backup's own settings cannot be put back after the
database was restored, `--salvage` no longer says the database is still the fresh one.
- A connection that breaks while opening a pack on the location (seen with SMB) is retried once and
is handled by `--salvage`, instead of stopping the restore with the error of the storage.
- Without a system cron, several running backups take turns while the page is open.
- The Bin tab shows how far it is while it reads the removed restore points, which can take a while.
It also shows when it cannot read a location, instead of an empty bin, and refreshes when a run
ends.
## [1.0.2] - 2026-11-06
### Added
+8
View File
@@ -452,6 +452,14 @@ in README "Known limitations":
via de pagina (eerlijke beurten, stappen die de webtimeout halen); Bin verversen en fouten
tonen; SMB-uitzondering bij het openen van een pack onder `--salvage`.
Niet doen: bij hervatten de inhoud vergelijken (grootte en mtime is ook het criterium van de backup).
**Stand op branch `1.0.3` (2026-11-06):** punten 1-6 gebouwd. Lege mappen staan in het metarecord
van de snapshot, niet tussen de bestanden: 1.0.2 en ouder lezen zo'n snapshot gewoon en slaan alleen
de lege mappen over. Geen webtimeout-wijziging zonder cron: alleen in de code nagelopen, niet getest
(Nextcloud zet de PHP-tijdslimiet ruim, een gestopte stap wordt overgedaan); wel eerlijke beurten.
Bin leest in delen met teller en balk (besluit 2026-11-06, ook met oog op meerdere backups per
dag). Getest: unit (volle schijf op echte tmpfs), integratie NC35 sqlite/pgsql/mysql, sftp, salvage,
salvage-files; Bin in een echte browser met vertraagde verzoeken (1 van 7 t/m 7). Nog: kleine
volledige simulaties, reviews vóór de PR, README-regels bij de versiecommit.
- **Branches (besluit 2026-11-06):** werk voor een versie op een branch met die naam (`1.0.3`);
`polish` alleen voor kleine cosmetische zaken, per geval bekijken of en wanneer die gemerged
worden. SMB staat uit op sub5 en sub6; sub6 is het testaccount, eerst aanzetten voor een SMB-test.
+9 -7
View File
@@ -123,10 +123,12 @@ without `--install-apps`:
occ maintenance:mode --off && occ backup:restore:full offsite <snapshot> --salvage --force
- Files that cannot be read are left out and listed at the end; everything else is restored.
- **Check the disk first if the reason is "Size mismatch".** In this version a full disk looks like
damaged data: the message then points at `--salvage`, and with `--salvage` the restore goes on and
lists the remaining files. The backup itself is fine: make room and run the restore again as in
step 8, without `--salvage` unless the backup is damaged as well. To be fixed in 1.0.3.
- A full disk is not damage: the restore stops, also with `--salvage`, and says so. Make room and
run the restore again as in step 8.
- **On version 1.0.2, check the disk first if the reason is "Size mismatch".** There a full disk
looks like damaged data: the message points at `--salvage`, and with `--salvage` the restore goes
on and lists the remaining files. The backup itself is fine. Update NG Backup, make room and run
the restore again as in step 8.
- If the database cannot be restored (its dump is damaged, or it does not fit this
installation), the database stays as the fresh installation made it, and the data directory
and config of the backup go to a folder of their own in the data directory,
@@ -149,9 +151,9 @@ The command then ends with exit code 2 instead of 0. The same option exists for
- From Nextcloud All-in-One to a regular server: also check the cache settings (`memcache.*`,
`redis`), which point at AIO's containers.
- Log in, open a few files, a calendar and a contact.
- Empty folders do not come back: a backup only records files (to be fixed in 1.0.3, see
[issue #27](https://github.com/dillardblom/ng_backup/issues/27)). `occ files:scan` then removes
them from the file cache, so it reports them as removed.
- Empty folders only come back from backups made with version 1.0.3 or newer. With an older
restore point they are gone: `occ files:scan` then removes them from the file cache, so it reports
them as removed.
- Start background jobs again (cron), as in step 2 in reverse.
**10. Point your domain at the new server** and check that clients reconnect.
+7 -1
View File
@@ -124,7 +124,13 @@ final class StorageBackend implements IBackend, IReadsAhead {
// null: not supported or failing for now; false: not opened, which need not mean
// missing. Either way the storage's own stream decides.
}
$fh = $this->storage->fopen($this->abs($path), 'r');
try {
$fh = $this->storage->fopen($this->abs($path), 'r');
} catch (\Exception $e) {
// Some storages throw for what is not a missing file (SMB: a connection that broke).
// As a BackendException, the caller reads once more and --salvage can go on.
throw new BackendException('Cannot read ' . $path . ': ' . $e->getMessage(), 0, $e);
}
if ($fh === false) {
throw new BackendException('Not found: ' . $path);
}
+12 -4
View File
@@ -82,6 +82,8 @@ final class AdminApiController extends Controller {
/** Seconds of backup work one tick() does: well within a normal web request. */
public const TICK_SECONDS = 10.0;
/** Seconds one request for the Bin reads, before the page shows how far it is and asks for more. */
private const TRASH_PAGE_SECONDS = 2.0;
/**
* Move running backups forward from the open admin page, when background jobs do not run on a
@@ -95,7 +97,10 @@ final class AdminApiController extends Controller {
}
$deadline = microtime(true) + self::TICK_SECONDS;
$stepped = 0;
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
$running = $this->runs->findRunning(null, BackupService::KIND_FULL);
// Longest wait first: a run that takes all of a tick does not keep another from its turn.
usort($running, static fn ($a, $b) => $a->getUpdatedAt() <=> $b->getUpdatedAt());
foreach ($running as $run) {
if (microtime(true) >= $deadline) {
break;
}
@@ -174,15 +179,18 @@ final class AdminApiController extends Controller {
}
}
public function trash(int $targetId): JSONResponse {
public function trash(int $targetId, int $offset = 0): JSONResponse {
try {
$delay = $this->keys->deleteDelayDays() * 86400;
$repo = $this->targets->repository($this->targets->get((string)$targetId));
// In parts, so that the page can show how far it is (a read per removed restore point).
$page = $repo->trashPage($offset, microtime(true) + self::TRASH_PAGE_SECONDS);
$out = [];
foreach ($this->targets->repository($this->targets->get((string)$targetId))->trash() as $id => $info) {
foreach ($page['items'] as $id => $info) {
$out[] = ['id' => $id, 'label' => $info['label'], 'time' => $info['time'], 'removedAt' => $info['forgottenAt'],
'by' => $info['by'], 'deleteAfter' => $info['forgottenAt'] + $delay];
}
return new JSONResponse($out);
return new JSONResponse(['total' => $page['total'], 'items' => $out]);
} catch (\Throwable $e) {
return new JSONResponse(['error' => $e->getMessage()], Http::STATUS_BAD_REQUEST);
}
+29 -6
View File
@@ -157,13 +157,18 @@ final class DbDumper {
}
// Tables the backup knows the structure of are created first (DDL is not transactional
// everywhere); tables it does not know are left out, as asked.
foreach ($missing as $table) {
if (isset($manifest['tables'][$table]['schema'])) {
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
$report['created'][] = $table;
} else {
$report['missing'][] = $table;
try {
foreach ($missing as $table) {
if (isset($manifest['tables'][$table]['schema'])) {
$this->db->createSchemaManager()->createTable(self::tableFromStructure($table, $manifest['tables'][$table]['schema'], $this->tableOptions()));
$report['created'][] = $table;
} else {
$report['missing'][] = $table;
}
}
} catch (\Throwable $e) {
$this->dropCreatedTables($report);
throw $e;
}
$total = 0;
$foreignKeys = [];
@@ -242,11 +247,29 @@ final class DbDumper {
if (in_array('SET FOREIGN_KEY_CHECKS = 1', $foreignKeys, true)) {
$this->db->executeStatement('SET FOREIGN_KEY_CHECKS = 1');
}
$this->dropCreatedTables($report);
throw $e;
}
return $total;
}
/**
* The restore did not go through: a rollback does not undo CREATE TABLE everywhere. Left behind,
* the empty tables of an app would stop that app's installation later ("table already exists").
*
* @param array{created: list<string>, ...} $report
*/
private function dropCreatedTables(array &$report): void {
foreach ($report['created'] as $table) {
try {
$this->db->createSchemaManager()->dropTable($table);
} catch (\Throwable) {
// the error that brought us here is the one to report; a run again uses the table
}
}
$report['created'] = [];
}
/**
* The table prefix of the instance the dump was made on (from its appconfig table), or null
* when the dump has none.
+33 -1
View File
@@ -55,9 +55,28 @@ final class BackupRun {
});
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
$lines = [];
$emptyDirs = [];
$finishedWalk = true;
foreach ($walker->walk($state['after']) as $logical => $path) {
if (is_dir($path)) {
$mtime = @filemtime($path);
if ($mtime === false) {
continue; // removed while walking
}
if (mb_check_encoding($logical, 'UTF-8')) {
$emptyDirs[] = json_encode([$logical, $mtime], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$stats['emptyDirs'] = ($stats['emptyDirs'] ?? 0) + 1;
} else {
self::recordUnreadable($state, $stats, $logical); // a snapshot only holds UTF-8
}
$state['after'] = $logical;
if (microtime(true) >= $deadline) {
$finishedWalk = false;
break;
}
continue;
}
$version = Repository::fileVersion($path);
if ($version === 'missing') {
continue; // deleted while walking
@@ -136,6 +155,11 @@ final class BackupRun {
$writer->write(implode("\n", $lines) . "\n");
$state['segments'][] = $writer->finish();
}
if ($emptyDirs !== []) {
$writer = $repo->blobWriter($stats);
$writer->write(implode("\n", $emptyDirs) . "\n");
$state['dirSegments'][] = $writer->finish();
}
$repo->flushPacks();
$state['parentPos'] = $parent->position();
$state['stats'] = $stats;
@@ -150,10 +174,18 @@ final class BackupRun {
if ($state['phase'] === 'finish') {
// Every segment ends with a newline, so they read as one stream: fetched together,
// several at a time where the location allows it.
// Empty folders go in the meta record, not between the files: a version that does not
// know them reads the snapshot as before, and only restores no empty folders.
$emptyDirs = [];
foreach (isset($state['dirSegments']) ? $repo->readLines(array_values(array_merge(...$state['dirSegments']))) : [] as $line) {
if ($line !== '') {
$emptyDirs[] = json_decode($line, true, 8, JSON_THROW_ON_ERROR);
}
}
$repo->putSnapshot($state['snapshot'],
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
'warnings' => self::warnings($state)] + $state['meta'],
'warnings' => self::warnings($state)] + ($emptyDirs === [] ? [] : ['emptyDirs' => $emptyDirs]) + $state['meta'],
$repo->readLines(array_values(array_merge(...$state['segments']))));
$state['phase'] = 'commit';
}
+11 -2
View File
@@ -9,7 +9,9 @@ namespace OCA\NgBackup\Job;
/**
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
* by name, children right after their directory), yielding "<root>/<relative path>" for files
* and for directories with nothing in them to walk (the caller tells them apart with is_dir()).
* A root itself without a name is never yielded.
*
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
* and be merge-joined with the previous snapshot without holding any list in memory.
@@ -66,6 +68,7 @@ final class TreeWalker {
}
$names = array_values(array_diff($names, ['.', '..']));
sort($names, SORT_STRING);
$empty = true; // no file or directory below it that is walked, whatever the cursor skips
foreach ($names as $name) {
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
$childAbs = $abs . '/' . $name;
@@ -73,20 +76,26 @@ final class TreeWalker {
continue; // never follow symlinks out of the data directory
}
if (is_dir($childAbs)) {
$empty = $empty && $this->isExcluded($childLogical, true);
// Skip a directory entirely if everything in it sorts before the cursor.
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
continue;
}
yield from $this->walkDir($childAbs, $childLogical, $after);
} elseif (is_file($childAbs)) {
$excluded = $this->isExcluded($childLogical, false);
$empty = $empty && $excluded;
if ($after !== null && self::compare($childLogical, $after) <= 0) {
continue;
}
if (!$this->isExcluded($childLogical, false)) {
if (!$excluded) {
yield $childLogical => $childAbs;
}
}
}
if ($empty && $logical !== '' && ($after === null || self::compare($logical, $after) > 0)) {
yield $logical => $abs;
}
}
private function isExcluded(string $logical, bool $isDir): bool {
+131 -21
View File
@@ -44,6 +44,8 @@ final class Repository {
private ?int $maxBytes = null;
private Catalog $catalog;
private PackReader $packReader;
/** @var array<string, array<string, mixed>> meta record of the snapshot read last */
private array $lastMeta = [];
/** verify() lists the pack folders from this many packs on (see presentPacks()); tests lower it. */
public int $listPacksAbove = 256;
@@ -257,8 +259,8 @@ final class Repository {
// that was being written when the process was killed stays (8 MiB, DR test 2026-11-05)
$stale = glob(rtrim($target, '/') . '/.ngb-restore-spool-*', GLOB_ONLYDIR);
foreach ($stale === false ? [] : $stale as $dir) {
if (is_link($dir)) {
continue;
if (is_link($dir) || preg_match('/^\.ngb-restore-spool-[0-9a-f]{8}$/', basename($dir)) !== 1) {
continue; // not a spool of ours, whatever it is called
}
$files = glob($dir . '/*');
foreach ($files === false ? [] : $files as $file) {
@@ -313,40 +315,39 @@ final class Repository {
if (!self::underPrefix($entry['p'], $prefix)) {
continue;
}
if ($heartbeat !== null) {
$heartbeat(); // also for a long row of files that are skipped or have no data
}
$dest = self::destination($target, $entry['p'], $prefix, $stripPrefix);
if ($keepUnchanged && self::inPlace($dest, $entry)) {
// below the target as written by an earlier run of this restore (checked then)
$count++;
continue;
}
if (!is_dir(dirname($dest))) {
mkdir(dirname($dest), 0700, true);
}
// Defence in depth: the resolved directory must stay below the target (no symlink escapes).
$base = realpath($target);
$dir = realpath(dirname($dest));
if ($base === false || $dir === false || ($dir !== $base && !str_starts_with($dir, $base . '/')) || is_link($dest)) {
throw new RepositoryException('Refusing to write outside the restore directory: ' . $entry['p']);
$dir = self::directoryInTarget($target, dirname($dest), $entry['p']);
if (is_link($dest)) {
throw new RestoreTargetException('Refusing to write outside the restore directory: ' . $entry['p']);
}
if (!isset($cleaned[$dir])) {
self::removeStaleTemporaries($dir);
$cleaned[$dir] = true;
}
if ($keepUnchanged && self::inPlace($dest, $entry)) {
$count++;
continue;
}
// Write next to the destination and rename after all blobs verified: at most one file
// is ever present twice.
$tmp = $dest . '.ngb-restore-' . bin2hex(random_bytes(6));
$out = @fopen($tmp, 'xb'); // fails if anything (including a symlink) already exists there
if ($out === false) {
throw new RepositoryException('Cannot create a temporary file for ' . $entry['p']);
throw new RestoreTargetException('Cannot create a temporary file for ' . $entry['p'] . ' (disk full, or no permission?)');
}
try {
foreach ($entry['b'] as $id) {
fwrite($out, $this->restoreBlob($id, $plan));
self::writeAll($out, $this->restoreBlob($id, $plan), $entry['p']);
if ($heartbeat !== null) {
$heartbeat();
}
}
fclose($out);
if (!@fclose($out)) {
throw new RestoreTargetException('Cannot write ' . $entry['p'] . ' to the restore directory (disk full?)');
}
clearstatcache(true, $tmp);
if (filesize($tmp) !== $entry['s']) {
throw new RepositoryException('Size mismatch restoring ' . $entry['p']);
@@ -363,19 +364,102 @@ final class Repository {
}
$skipped[$entry['p']] = $e->getMessage();
continue;
} catch (\Throwable $e) {
if (is_resource($out)) {
@fclose($out);
}
@unlink($tmp);
throw $e;
}
if (!rename($tmp, $dest)) {
@unlink($tmp);
throw new RepositoryException('Cannot put the restored file in place: ' . $entry['p']);
throw new RestoreTargetException('Cannot put the restored file in place: ' . $entry['p']);
}
if (!touch($dest, $entry['m'])) {
throw new RepositoryException('Cannot set the modification time of ' . $entry['p']);
throw new RestoreTargetException('Cannot set the modification time of ' . $entry['p']);
}
$count++;
}
// Last: a file can only be restored into a folder that exists, and the meta record was
// read along with the files, so this asks nothing more of the location.
foreach ($this->emptyDirs($snapshotId) as [$path, $mtime]) {
if (self::underPrefix($path, $prefix)) {
self::restoreEmptyDir($target, self::destination($target, $path, $prefix, $stripPrefix), $path, $mtime);
}
}
return $count;
}
/**
* The folders without anything in them that a snapshot recorded (none before 1.0.3).
*
* @return list<array{0: string, 1: int}> path, modification time
*/
public function emptyDirs(string $snapshotId): array {
$meta = $this->lastMeta[$snapshotId] ?? $this->snapshotMeta($snapshotId);
$dirs = $meta['emptyDirs'] ?? [];
foreach (is_array($dirs) ? $dirs : [null] as $dir) {
if (!is_array($dir) || !is_string($dir[0] ?? null) || !self::isSafePath($dir[0]) || !is_int($dir[1] ?? null)) {
throw new RepositoryException('Invalid empty folder in snapshot ' . $snapshotId);
}
}
/** @var list<array{0: string, 1: int}> */
return $dirs;
}
private static function restoreEmptyDir(string $target, string $dest, string $path, int $mtime): void {
if (is_link($dest)) {
throw new RestoreTargetException('Refusing to write outside the restore directory: ' . $path);
}
$existed = is_dir($dest);
self::directoryInTarget($target, $dest, $path);
if (!$existed && !@touch($dest, $mtime)) {
throw new RestoreTargetException('Cannot set the modification time of ' . $path);
}
}
/**
* Make sure a folder exists below the target, without following a link out of it: what exists
* of the path is checked before anything is created.
*
* @return string the folder's real path
*/
private static function directoryInTarget(string $target, string $dir, string $path): string {
if (!is_dir($target) && !@mkdir($target, 0700, true) && !is_dir($target)) {
throw new RestoreTargetException('Cannot create the restore directory ' . $target);
}
$base = realpath($target);
$existing = $dir;
while (!file_exists($existing) && !is_link($existing) && dirname($existing) !== $existing) {
$existing = dirname($existing);
}
$real = realpath($existing);
if ($base === false || $real === false || ($real !== $base && !str_starts_with($real, $base . '/'))) {
throw new RestoreTargetException('Refusing to write outside the restore directory: ' . $path);
}
if ($existing === $dir) {
if (!is_dir($real)) {
throw new RestoreTargetException('Cannot create the folder for ' . $path . ': a file is in the way');
}
return $real;
}
if (!@mkdir($dir, 0700, true) && !is_dir($dir)) {
throw new RestoreTargetException('Cannot create the folder for ' . $path . ' (disk full, no permission, or a file in the way?)');
}
return (string)realpath($dir);
}
/**
* Write all of $data, or stop: a short write leaves a file that looks damaged only by its size.
*
* @param resource $out
*/
public static function writeAll($out, string $data, string $path): void {
if (@fwrite($out, $data) !== strlen($data)) {
throw new RestoreTargetException('Cannot write ' . $path . ' to the restore directory (disk full?)');
}
}
/** Where restore() writes the snapshot path $path. */
private static function destination(string $target, string $path, string $prefix, bool $stripPrefix): string {
$relPath = $path;
@@ -722,6 +806,29 @@ final class Repository {
return array_keys($doomed);
}
/**
* Part of trash(), for a page that shows how far it is: each entry is a read of its own on the
* location. Reads on from $offset until $deadline (microtime(true)), at least one entry.
*
* @return array{total: int, items: array<string, array<string, mixed>>}
*/
public function trashPage(int $offset, float $deadline): array {
$ids = array_map('strval', array_keys($this->catalog->trash));
sort($ids, SORT_STRING);
$items = [];
foreach (array_slice($ids, max(0, $offset)) as $id) {
try {
$items[$id] = $this->trashInfo($id);
} catch (\Throwable) {
throw new RepositoryException("Trash entry $id listed in the catalog is missing on the location (possible tampering)");
}
if (microtime(true) >= $deadline) {
break;
}
}
return ['total' => count($ids), 'items' => $items];
}
private function trashInfo(string $snapshotId): array {
return json_decode($this->getObject('trash/info/' . $snapshotId), true, 512, JSON_THROW_ON_ERROR);
}
@@ -1281,7 +1388,10 @@ final class Repository {
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
fclose($fh);
rewind($plain);
fgets($plain); // meta line
$meta = json_decode((string)fgets($plain), true);
if (is_array($meta)) {
$this->lastMeta = [$snapshotId => $meta]; // a snapshot never changes
}
while (($line = fgets($plain)) !== false) {
$entry = json_decode($line, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($entry) || !is_string($entry['p'] ?? null) || !self::isSafePath($entry['p'])
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Repository;
/**
* A restore cannot write to where it restores to (a full disk, permissions, something in the way).
* The backup is fine, so this never counts as damaged data: the restore stops, also with --salvage.
*/
final class RestoreTargetException extends \RuntimeException {
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Service;
/** A step after the database was committed failed: the database is no longer the fresh one. */
final class DatabaseAlreadyRestoredException extends \RuntimeException {
}
+15 -1
View File
@@ -175,6 +175,8 @@ final class RestoreFullService {
if ($dbError === null) {
try {
$this->restoreDatabase($repo, $meta, $manifest, $dbReport, $beat, $skipMissingTables);
} catch (DatabaseAlreadyRestoredException $e) {
throw $e;
} catch (\Throwable $e) {
if (!$salvage) {
throw $e;
@@ -259,8 +261,20 @@ final class RestoreFullService {
$dumper = new DbDumper($this->db->getInner(), $prefix);
try {
$dumper->restore($repo, $manifest, $dbReport, $beat, $skipMissingTables);
} finally {
} catch (\Throwable $e) {
try {
$this->restoreOwnAppConfig($ownAppConfig);
} catch (\Throwable) {
// rolled back, so they are still there: the error that brought us here is the one to report
}
throw $e;
}
try {
$this->restoreOwnAppConfig($ownAppConfig);
} catch (\Throwable $e) {
// The old server's database is in place by now: not a case for "the database stays as
// the fresh installation made it" under --salvage. A run again restores these rows.
throw new DatabaseAlreadyRestoredException('The database was restored, but NG Backup could not put back its own settings: ' . $e->getMessage(), 0, $e);
}
}
+23 -3
View File
@@ -75,6 +75,12 @@ final class RestoreService {
}
$children[$name] = $c;
}
foreach ($this->targets->repository($target)->emptyDirs($snapshotId) as [$dir]) {
if ($prefix === '' || str_starts_with($dir, $prefix)) {
$name = explode('/', substr($dir, strlen($prefix)), 2)[0];
$children[$name] ??= ['name' => $name, 'type' => 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
}
}
ksort($children, SORT_STRING);
return array_values($children);
}
@@ -123,7 +129,17 @@ final class RestoreService {
$entries[substr($e['p'], strlen($source) + 1)] = $e;
}
}
if ($entries === []) {
// Folders without anything in them, relative to $source ('' when $source itself is one).
/** @var array<array-key, true> $emptyDirs a folder called "2026" is an integer key */
$emptyDirs = [];
foreach ($single ? [] : $repo->emptyDirs($snapshotId) as [$dir]) {
if ($dir === $source) {
$emptyDirs[''] = true;
} elseif (str_starts_with($dir, $source . '/')) {
$emptyDirs[substr($dir, strlen($source) + 1)] = true;
}
}
if ($entries === [] && $emptyDirs === []) {
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
}
@@ -156,9 +172,13 @@ final class RestoreService {
}
}
foreach (array_keys($emptyDirs) as $dir) {
$this->ensureFolder($dest, (string)$dir);
}
$trashed = 0;
if ($mode === self::MODE_REPLACE && !$single) {
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)) + $emptyDirs);
}
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
}
@@ -262,7 +282,7 @@ final class RestoreService {
foreach ($folder->getDirectoryListing() as $node) {
$rel = $prefix === '' ? $node->getName() : $prefix . '/' . $node->getName();
if ($node instanceof Folder) {
$hasKept = false;
$hasKept = isset($keep[$rel]); // a folder that was empty in the snapshot
foreach (array_keys($keep) as $k) {
if (str_starts_with((string)$k, $rel . '/')) {
$hasKept = true;
+1
View File
@@ -186,6 +186,7 @@
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[$this->db]]></code>
<code><![CDATA[TransactionIsolationLevel]]></code>
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
<code><![CDATA[\Doctrine\DBAL\ParameterType]]></code>
+1 -1
View File
@@ -21,7 +21,7 @@ export default {
addSlot: (passphrase, label) => call('post', '/key/slots', { passphrase, label }),
replaceSlot: (slot, passphrase, label) => call('put', `/key/slots/${slot}`, { passphrase, label }),
removeSlot: (slot) => call('delete', `/key/slots/${slot}`),
trash: (targetId) => call('get', `/targets/${targetId}/trash`),
trash: (targetId, offset = 0) => call('get', `/targets/${targetId}/trash`, { offset }),
untrash: (targetId, snapshotId) => call('post', `/targets/${targetId}/trash/${snapshotId}`),
kitUrl: () => url('/key/kit'),
confirmKit: (accepted, phrase, code) => call('post', '/key/confirm', { accepted, phrase, code }),
+52 -11
View File
@@ -6,7 +6,17 @@
<NcSettingsSection :name="t('ng_backup', 'Bin')"
:description="t('ng_backup', 'Restore points that were removed, most of them by the retention policy. They can be brought back until they are deleted permanently.')">
<NcNoteCard v-if="error" type="error">{{ error }}</NcNoteCard>
<NcLoadingIcon v-if="loading" :size="32" />
<div v-if="reading" class="ngb-reading" role="status">
<template v-if="reading.total === null">
<NcLoadingIcon :size="20" />
<span>{{ t('ng_backup', 'Connecting to {location} …', { location: reading.name }) }}</span>
</template>
<template v-else>
<span>{{ t('ng_backup', 'Reading removed restore points from {location}: {done} of {total}', { location: reading.name, done: reading.done, total: reading.total }) }}</span>
<NcProgressBar :value="reading.total ? Math.round(100 * reading.done / reading.total) : 0" />
</template>
<p class="ngb-muted">{{ t('ng_backup', 'This can take a while: every removed restore point is read from the location.') }}</p>
</div>
<NcNoteCard v-else-if="empty" type="info">{{ t('ng_backup', 'The bin is empty.') }}</NcNoteCard>
<template v-for="target in status.targets" :key="'trash' + target.id">
@@ -39,12 +49,13 @@
</template>
<script setup>
import { computed, reactive, ref, watch } from 'vue'
import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { n, t } from '@nextcloud/l10n'
import { confirmPassword } from '@nextcloud/password-confirmation'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcNoteCard from '@nextcloud/vue/components/NcNoteCard'
import NcProgressBar from '@nextcloud/vue/components/NcProgressBar'
import NcSettingsSection from '@nextcloud/vue/components/NcSettingsSection'
import api from '../api.js'
import { formatDate, formatDateTime } from '../format.js'
@@ -56,21 +67,49 @@ const emit = defineEmits(['changed'])
const RETENTION = 'retention policy'
const retentionInfo = ref(false)
const trash = reactive({})
const loading = ref(true)
const reading = ref({ name: '', total: null, done: 0 }) // the location being read; null when done
const error = ref('')
const empty = computed(() => props.status.targets.every(target => !trash[target.id]?.length))
async function loadTrash() {
for (const target of props.status.targets) {
try {
trash[target.id] = await api.trash(target.id)
} catch (e) {
trash[target.id] = []
let current = 0 // the newest load; an older one stops (another took over, or the tab was left)
onBeforeUnmount(() => { current++ })
async function loadTarget(target, load) {
reading.value = { name: target.name, total: null, done: 0 }
const items = []
for (;;) {
const page = await api.trash(target.id, items.length)
if (load !== current) {
return
}
items.push(...page.items)
reading.value = { name: target.name, total: page.total, done: Math.min(items.length, page.total) }
if (items.length >= page.total || !page.items.length) {
break
}
}
loading.value = false
trash[target.id] = items.sort((a, b) => String(b.time ?? '').localeCompare(String(a.time ?? '')))
}
watch(() => props.status.targets.map(tg => tg.id).join(','), loadTrash, { immediate: true })
async function loadTrash() {
const load = ++current
const failed = []
for (const target of props.status.targets) {
try {
await loadTarget(target, load)
} catch (e) {
// keep what was shown: a location that cannot be reached does not mean its bin is empty
failed.push(target.name + (e?.message ? ': ' + e.message : ''))
}
if (load !== current) {
return
}
}
error.value = failed.length ? t('ng_backup', 'The bin could not be read from {locations}', { locations: failed.join('; ') }) : ''
reading.value = null
}
// Again when a run ends: a cleanup moves restore points here.
watch(() => props.status.targets.map(tg => tg.id).join(',') + '|' + (props.status.runs ?? []).filter(run => run.status !== 'running').map(run => run.id).join(','), loadTrash, { immediate: true })
async function untrash(target, item) {
error.value = ''
@@ -88,6 +127,8 @@ async function untrash(target, item) {
<style scoped>
.ngb-info { background: none; border: none; padding: 0 4px; min-height: auto; cursor: pointer; color: var(--color-primary-element); }
.ngb-muted { color: var(--color-text-maxcontrast); }
.ngb-reading { margin: 12px 0; display: flex; flex-direction: column; gap: 6px; max-width: 600px; }
.ngb-reading p { margin: 0; }
.ngb-trash { margin: 12px 0; }
.ngb-trash h3 { font-weight: bold; }
</style>
+22
View File
@@ -28,6 +28,28 @@ class TreeWalkerTest extends TestCase {
$this->assertSame(['Z', 'a', 'a-b', 'a.txt', 'b', 'skip'], array_values(array_unique(array_map(fn ($p) => explode('/', $p)[0], $paths))));
}
public function testEmptyDirectoriesAreYieldedInOrderAndOnResume(): void {
$d = $this->tree();
mkdir("$d/a/empty");
mkdir("$d/a-hollow/inner", 0700, true);
symlink("$d/a.txt", "$d/skip/link");
mkdir("$d/only-link");
symlink("$d/a.txt", "$d/only-link/link");
$w = new TreeWalker(['' => $d]);
$all = array_keys(iterator_to_array($w->walk()));
$this->assertContains('a/empty', $all);
$this->assertContains('a-hollow/inner', $all);
$this->assertNotContains('a-hollow', $all, 'only the deepest');
$this->assertContains('only-link', $all, 'a link is not walked');
$this->assertNotContains('skip', $all);
$sorted = $all;
usort($sorted, [TreeWalker::class, 'compare']);
$this->assertSame($sorted, $all);
foreach ($all as $i => $cursor) {
$this->assertSame(array_slice($all, $i + 1), array_keys(iterator_to_array($w->walk($cursor))), "after $cursor");
}
}
public function testResumeAfterCursorYieldsTheRest(): void {
$w = new TreeWalker(['' => $this->tree()]);
$all = array_keys(iterator_to_array($w->walk()));
+20
View File
@@ -50,6 +50,26 @@ class CatalogTest extends TestCase {
$this->assertSame(4, $again->catalogGeneration());
}
public function testTheTrashIsReadInPartsThatTogetherAreAll(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
for ($i = 0; $i < 3; $i++) {
$repo->forget($repo->backupDirectory($this->src)['snapshot']);
}
$again = $this->open();
$seen = [];
// A deadline already past: one entry per request, as on a slow location
for ($request = 0; count($seen) < 3 && $request < 5; $request++) {
$page = $again->trashPage(count($seen), 0.0);
$this->assertSame(3, $page['total']);
$this->assertCount(1, $page['items']);
$seen += $page['items'];
}
$this->assertSame(array_keys($again->trash()), array_intersect(array_keys($again->trash()), array_keys($seen)));
$this->assertCount(3, $seen);
$this->assertSame(['total' => 3, 'items' => []], $again->trashPage(3, 0.0));
$this->assertCount(3, $again->trashPage(0, microtime(true) + 60)['items']);
}
public function testRollbackIsDetected(): void {
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor);
$repo->backupDirectory($this->src);
+127
View File
@@ -0,0 +1,127 @@
<?php
declare(strict_types=1);
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\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
/** Folders without files are recorded in the snapshot's meta record and come back on a restore. */
class EmptyDirsTest extends TestCase {
use TempDirTrait;
private LocalBackend $backend;
private KeyRing $keys;
private string $src;
protected function setUp(): void {
$this->backend = new LocalBackend($this->tempDir());
$this->keys = KeyRing::generate();
Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
$this->src = $this->tempDir();
mkdir("$this->src/data/alice/files/Docs", 0700, true);
mkdir("$this->src/data/alice/files/Hollow/Nested", 0700, true);
mkdir("$this->src/data/skipped/inner", 0700, true);
file_put_contents("$this->src/data/alice/files/Docs/note.txt", "hello\n");
touch("$this->src/data/alice/files/Hollow/Nested", 1700000000);
}
private function repo(): Repository {
return Repository::openWithKey($this->backend, $this->keys);
}
/** @param list<string> $exclude */
private function backup(array $exclude = [], float $seconds = 30.0): string {
$state = BackupRun::start(['data' => "$this->src/data"], null, '', $exclude);
for ($i = 0; $i < 500 && $state['phase'] !== 'commit'; $i++) {
$state = json_decode(json_encode(BackupRun::step($this->repo(), $state, microtime(true) + $seconds)), true);
}
$this->assertSame('commit', $state['phase']);
$this->repo()->recordSnapshot($state['snapshot']);
return $state['snapshot'];
}
public function testOnlyTheDeepestEmptyFolderIsRecordedAndFilesStayAsTheyAre(): void {
$snapshot = $this->backup();
$paths = array_column($this->repo()->emptyDirs($snapshot), 0);
$this->assertSame(['data/alice/files/Hollow/Nested', 'data/skipped/inner'], $paths);
$this->assertSame(['data/alice/files/Docs/note.txt'], array_column(iterator_to_array($this->repo()->entries($snapshot), false), 'p'));
$this->assertSame(2, $this->repo()->snapshotMeta($snapshot)['stats']['emptyDirs']);
}
public function testAFolderWithOnlyExcludedContentCountsAsEmpty(): void {
$snapshot = $this->backup(['data/skipped/inner']);
$this->assertContains('data/skipped', array_column($this->repo()->emptyDirs($snapshot), 0));
$this->assertNotContains('data/skipped/inner', array_column($this->repo()->emptyDirs($snapshot), 0));
}
public function testAResumedWalkRecordsEveryFolderOnce(): void {
for ($i = 0; $i < 30; $i++) {
mkdir("$this->src/data/many/d$i", 0700, true);
file_put_contents("$this->src/data/many/f$i", 'x');
}
$snapshot = $this->backup([], 0.0); // a step per entry
$paths = array_column($this->repo()->emptyDirs($snapshot), 0);
$this->assertCount(32, $paths);
$this->assertSame(array_unique($paths), $paths);
}
public function testRestoreBringsThemBackWithTheirTime(): void {
$snapshot = $this->backup();
$target = $this->tempDir();
$this->assertSame(1, $this->repo()->restore($snapshot, $target));
$this->assertDirectoryExists("$target/data/alice/files/Hollow/Nested");
clearstatcache();
$this->assertSame(1700000000, filemtime("$target/data/alice/files/Hollow/Nested"));
$this->assertDirectoryExists("$target/data/skipped/inner");
}
public function testRestoreOfAPrefixStripsAndLeavesOthersOut(): void {
$snapshot = $this->backup();
$target = $this->tempDir();
$this->repo()->restore($snapshot, $target, 'data/alice/', true);
$this->assertDirectoryExists("$target/files/Hollow/Nested");
$this->assertDirectoryDoesNotExist("$target/skipped");
$this->assertDirectoryDoesNotExist("$target/inner");
}
public function testRunningAgainLeavesAFolderThatIsThereAlone(): void {
$snapshot = $this->backup();
$target = $this->tempDir();
$this->repo()->restore($snapshot, $target);
touch("$target/data/alice/files/Hollow/Nested", 1800000000);
file_put_contents("$target/data/alice/files/Hollow/Nested/added", 'kept');
touch("$target/data/alice/files/Hollow/Nested", 1800000000);
$this->repo()->restore($snapshot, $target, keepUnchanged: true);
clearstatcache();
$this->assertSame(1800000000, filemtime("$target/data/alice/files/Hollow/Nested"));
$this->assertFileExists("$target/data/alice/files/Hollow/Nested/added");
}
public function testALinkOutOfTheTargetIsRefused(): void {
$snapshot = $this->backup();
$target = $this->tempDir();
$outside = $this->tempDir();
mkdir("$target/data/alice/files", 0700, true);
symlink($outside, "$target/data/alice/files/Hollow");
try {
$this->repo()->restore($snapshot, $target);
$this->fail('restored through a link');
} catch (\OCA\NgBackup\Repository\RestoreTargetException $e) {
$this->assertStringContainsString('outside', $e->getMessage());
}
$this->assertSame([], glob("$outside/*"));
}
public function testASnapshotWithoutThemHasNone(): void {
$src = $this->tempDir();
file_put_contents("$src/a", 'x');
$snapshot = $this->repo()->backupDirectory($src)['snapshot'];
$this->assertSame([], $this->repo()->emptyDirs($snapshot));
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
declare(strict_types=1);
namespace OCA\NgBackup\Tests\Unit\Repository;
use OCA\NgBackup\Backend\LocalBackend;
use OCA\NgBackup\Command\Restore;
use OCA\NgBackup\Crypto\KeyRing;
use OCA\NgBackup\Repository\Repository;
use OCA\NgBackup\Repository\RestoreTargetException;
use OCA\NgBackup\Tests\Unit\TempDirTrait;
use PHPUnit\Framework\TestCase;
/**
* What goes wrong where a restore writes to is not damage in the backup: it stops the restore, also
* with --salvage, and the message does not point at --salvage.
*/
class RestoreTargetTest extends TestCase {
use TempDirTrait;
private LocalBackend $backend;
private KeyRing $keys;
private string $snapshot;
protected function setUp(): void {
$this->backend = new LocalBackend($this->tempDir());
$this->keys = KeyRing::generate();
$src = $this->tempDir();
mkdir("$src/dir");
file_put_contents("$src/dir/a.bin", random_bytes(6 * 1048576));
file_put_contents("$src/dir/b.bin", random_bytes(6 * 1048576));
file_put_contents("$src/dir/c.txt", 'small');
$this->snapshot = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'))->backupDirectory($src)['snapshot'];
}
private function repo(): Repository {
return Repository::openWithKey($this->backend, $this->keys);
}
public function testAShortWriteIsATargetError(): void {
if (!is_writable('/dev/full')) {
$this->markTestSkipped('needs /dev/full');
}
$out = fopen('/dev/full', 'wb');
stream_set_write_buffer($out, 0);
$this->expectException(RestoreTargetException::class);
Repository::writeAll($out, 'data', 'dir/a.bin');
}
public function testATargetErrorDoesNotLookLikeDamage(): void {
$this->assertFalse(Restore::backupLooksDamaged(new RestoreTargetException('Cannot write')));
}
/**
* A real full disk: run with NGB_TEST_SMALL_DISK set to an empty, writable tmpfs of a few MiB
* (docker run --tmpfs /small:size=8m,uid=...), with and without --salvage.
*
* @dataProvider salvageOrNot
*/
public function testAFullDiskStopsTheRestoreAndLeavesNoTemporary(bool $salvage): void {
$disk = getenv('NGB_TEST_SMALL_DISK');
if ($disk === false || $disk === '') {
$this->markTestSkipped('needs NGB_TEST_SMALL_DISK');
}
$target = $disk . '/' . bin2hex(random_bytes(4));
$skipped = $salvage ? [] : null;
try {
$this->repo()->restore($this->snapshot, $target, skipped: $skipped);
$this->fail('12 MiB fitted on the small disk');
} catch (RestoreTargetException $e) {
$this->assertStringContainsString('disk full', $e->getMessage());
} finally {
$left = glob("$target/dir/*.ngb-restore-*");
foreach (glob("$target/dir/*") ?: [] as $f) {
unlink($f);
}
@rmdir("$target/dir");
@rmdir($target);
}
$this->assertSame([], $left);
if ($salvage) {
$this->assertSame([], $skipped, 'nothing is listed as damaged');
}
}
/** @return array<string, array{bool}> */
public static function salvageOrNot(): array {
return ['without --salvage' => [false], 'with --salvage' => [true]];
}
public function testResumeChecksTheFolderBeforeSkipping(): void {
$target = $this->tempDir();
$this->repo()->restore($this->snapshot, $target);
$outside = $this->tempDir();
rename("$target/dir", "$outside/dir");
symlink("$outside/dir", "$target/dir");
$this->expectException(RestoreTargetException::class);
$this->repo()->restore($this->snapshot, $target, keepUnchanged: true);
}
public function testResumeRemovesTemporariesWhereEverythingIsInPlace(): void {
$target = $this->tempDir();
$this->repo()->restore($this->snapshot, $target);
file_put_contents("$target/dir/a.bin.ngb-restore-0123456789ab", 'left by a stopped restore');
$this->repo()->restore($this->snapshot, $target, keepUnchanged: true);
$this->assertFileDoesNotExist("$target/dir/a.bin.ngb-restore-0123456789ab");
}
public function testHeartbeatRunsForFilesThatAreSkipped(): void {
$target = $this->tempDir();
$this->repo()->restore($this->snapshot, $target);
$beats = 0;
$this->repo()->restore($this->snapshot, $target, '', false, function () use (&$beats): void {
$beats++;
}, keepUnchanged: true);
$this->assertSame(3, $beats);
}
}
+9 -3
View File
@@ -102,7 +102,7 @@ ok "key, kit and location on the origin"
NC_PASS=$(openssl rand -hex 16)
docker exec -u www-data -e OC_PASS="$NC_PASS" ngb-frt-origin php occ user:add --password-from-env frtest >/dev/null
docker exec ngb-frt-origin sh -c "mkdir -p /var/www/html/data/frtest/files/Docs && head -c 200000 /dev/urandom > /var/www/html/data/frtest/files/Docs/report.bin && echo 'hello disaster recovery' > /var/www/html/data/frtest/files/Docs/note.txt && chown -R www-data:www-data /var/www/html/data/frtest"
docker exec ngb-frt-origin sh -c "mkdir -p /var/www/html/data/frtest/files/Docs /var/www/html/data/frtest/files/Empty/Nested && head -c 200000 /dev/urandom > /var/www/html/data/frtest/files/Docs/report.bin && echo 'hello disaster recovery' > /var/www/html/data/frtest/files/Docs/note.txt && chown -R www-data:www-data /var/www/html/data/frtest"
if [ "$MODE" = sftp ]; then
# about three packs of 32 MiB
docker exec ngb-frt-origin sh -c "head -c 90000000 /dev/urandom > /var/www/html/data/frtest/files/Docs/large.bin && chown www-data:www-data /var/www/html/data/frtest/files/Docs/large.bin"
@@ -214,6 +214,7 @@ if [ "$MODE" = salvage ]; then
printf '%s\n' "$OUT" | grep "$SALVAGE" >/dev/null || { printf '%s\n' "$OUT" | tail -30; fail "the output does not name the salvage folder"; }
[ "$(docker exec ngb-frt-restore cat "$SALVAGE/data/frtest/files/Docs/note.txt")" = "hello disaster recovery" ] || fail "the user's file is not in the salvage folder"
docker exec ngb-frt-restore test -f "$SALVAGE/config/config.php" || fail "config.php is not in the salvage folder"
docker exec ngb-frt-restore test -d "$SALVAGE/data/frtest/files/Empty/Nested" || fail "the empty folder is not in the salvage folder"
run_on ngb-frt-restore user:list | grep frtest >/dev/null && fail "the fresh database was changed (the backed-up user is there)"
run_on ngb-frt-restore status 2>&1 | grep "maintenance: false" >/dev/null || fail "maintenance mode is still on after the salvage"
run_on ngb-frt-restore backup:status >/dev/null || fail "backup:status fails after the salvage"
@@ -254,8 +255,8 @@ if [ "$MODE" = salvage-files ]; then
if (!file_exists($root . $p)) { $bad[] = "not listed and not there: $p"; continue; }
hash_file("sha256", $root . $p) === $sum ? $same++ : $bad[] = "different: $p";
}
$temps = glob($root . "*/*.ngb-restore-*") ?: [];
foreach ($temps as $t) { $bad[] = "temporary left: $t"; }
$walk = new RecursiveIteratorIterator(new RecursiveDirectoryIterator("/var/www/html/data", FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST);
foreach ($walk as $f) { if (str_contains($f->getFilename(), ".ngb-restore-")) { $bad[] = "temporary left: " . $f->getPathname(); } }
echo "same=$same gone=$gone bad=", count($bad), "\n", implode("\n", array_slice($bad, 0, 10)), "\n";
echo "reasons: ", implode(" | ", array_unique(array_map(fn ($r) => substr(preg_replace("#[0-9a-f]{16,}#", "<id>", $r), 0, 70), $reasons))), "\n";')
printf '%s\n' "$CHECK"
@@ -265,6 +266,8 @@ if [ "$MODE" = salvage-files ]; then
ok "with --salvage: every file listed and absent, or there and identical"
run_on ngb-frt-restore status 2>&1 | grep "maintenance: false" >/dev/null || fail "maintenance mode is still on after the salvage"
run_on ngb-frt-restore user:list | grep frtest >/dev/null || fail "the backed-up user is missing: the database was not restored"
run_on ngb-frt-restore user:list | grep admin >/dev/null || fail "the admin is missing: the database was not restored"
docker exec ngb-frt-restore test -d /var/www/html/data/frtest/files/Empty/Nested || fail "the empty folder did not come back with --salvage"
[ "$(docker exec ngb-frt-restore cat /var/www/html/data/frtest/files/Docs/note.txt 2>/dev/null)" = "hello disaster recovery" ] \
|| printf '%s\n' "$OUT" | grep "Docs/note.txt" >/dev/null || fail "note.txt is neither restored nor listed"
run_on ngb-frt-restore backup:status >/dev/null || fail "backup:status fails after the salvage"
@@ -323,7 +326,10 @@ run_on ngb-frt-restore backup:status >/dev/null || fail "backup:status fails aft
run_on ngb-frt-restore user:list | grep frtest >/dev/null || fail "the backed-up user is missing from the restored database"
docker exec ngb-frt-restore test -f /var/www/html/data/frtest/files/Docs/note.txt || fail "restored file is missing"
[ "$(docker exec ngb-frt-restore cat /var/www/html/data/frtest/files/Docs/note.txt)" = "hello disaster recovery" ] || fail "restored file content differs"
docker exec ngb-frt-restore test -d /var/www/html/data/frtest/files/Empty/Nested || fail "the empty folder did not come back"
run_on ngb-frt-restore files:scan frtest >/dev/null || fail "files:scan fails after restore"
docker exec ngb-frt-restore test -d /var/www/html/data/frtest/files/Empty/Nested || fail "files:scan removed the empty folder"
ok "the empty folder came back and stays after files:scan"
# New rows after the restore: a new file (file cache) and a new user must not collide with
# restored rows (ID counters after the data).
docker exec ngb-frt-restore sh -c "echo new > /var/www/html/data/frtest/files/Docs/after-restore.txt && chown www-data /var/www/html/data/frtest/files/Docs/after-restore.txt"
+12 -2
View File
@@ -26,7 +26,7 @@ ok "location added"
# Test data: a user with files.
$X sh -c "OC_PASS='Roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null"
# large.bin (40 MB) fills more than one pack: a restore to a directory reads ahead over several ranges
$X sh -c "mkdir -p $DATA/$U/files/Docs/Sub && for i in 1 2 3 4 5; do head -c 300000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done; echo hello > $DATA/$U/files/Docs/Sub/note.txt; head -c 6000000 /dev/urandom > $DATA/$U/files/big.bin; head -c 40000000 /dev/urandom > $DATA/$U/files/large.bin"
$X sh -c "mkdir -p $DATA/$U/files/Docs/Sub $DATA/$U/files/Docs/Empty/Nested $DATA/$U/files/Hollow && for i in 1 2 3 4 5; do head -c 300000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done; echo hello > $DATA/$U/files/Docs/Sub/note.txt; head -c 6000000 /dev/urandom > $DATA/$U/files/big.bin; head -c 40000000 /dev/urandom > $DATA/$U/files/large.bin"
occ files:scan "$U" >/dev/null
SUM_BEFORE=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
@@ -39,11 +39,17 @@ SNAP=$(occ backup:list ci | awk -F'|' '!f && / second /{gsub(/ /,"",$3); print $
[ -n "$SNAP" ] || fail "snapshot not listed"
# Damage, then restore in each mode.
$X sh -c "cd $DATA/$U/files && rm Docs/Sub/note.txt && echo broken > Docs/f1.bin && echo extra > Docs/extra.txt"
$X sh -c "cd $DATA/$U/files && rm Docs/Sub/note.txt && echo broken > Docs/f1.bin && echo extra > Docs/extra.txt && rm -r Docs/Empty && rmdir Hollow"
occ files:scan "$U" >/dev/null
occ backup:restore ci "$SNAP" "data/$U/files/Docs" -m new-folder >/dev/null || fail "restore new-folder"
$X sh -c "ls $DATA/$U/files | grep -q '^Restored '" || fail "no Restored folder"
$X sh -c "test -d $DATA/$U/files/Restored\ */Docs/Empty/Nested" || fail "the empty folder is not in the Restored folder"
occ backup:browse ci "$SNAP" "data/$U/files" | grep Hollow >/dev/null || fail "an empty folder is not shown when browsing the snapshot"
occ backup:restore ci "$SNAP" "data/$U/files/Hollow" -m merge >/dev/null || fail "restore of a folder that was empty"
$X sh -c "test -d $DATA/$U/files/Hollow" || fail "the empty folder did not come back by itself"
occ backup:restore ci "$SNAP" "data/$U/files/Docs" -m replace >/dev/null || fail "restore replace"
$X sh -c "test -d $DATA/$U/files/Docs/Empty/Nested" || fail "replace did not bring back the empty folder, or moved it to the trash"
ok "empty folders: restored in each mode, by themselves and shown when browsing"
$X sh -c "rm -rf '$DATA/$U/files/Restored '*" ; occ files:scan "$U" >/dev/null
SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after restore"
@@ -63,6 +69,7 @@ occ backup:restore ci "$SNAP" "data/$U/files" -d /tmp/ngb-rt-dir >/dev/null || f
SUM_DIR=$($X sh -c "cd /tmp/ngb-rt-dir/data/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
[ "$SUM_BEFORE" = "$SUM_DIR" ] || fail "the files restored to a directory differ"
$X sh -c "ls -A /tmp/ngb-rt-dir | grep -q ngb-restore-spool" && fail "the read-ahead spool was left in the directory"
$X sh -c "test -d /tmp/ngb-rt-dir/data/$U/files/Docs/Empty/Nested" || fail "the empty folder is not in the directory"
$X sh -c "rm -rf /tmp/ngb-rt-dir"
ok "restore of all the user's files to a directory: identical, no spool left"
# not grep -q: it stops reading at the first match, and occ, still writing, then fails (pipefail)
@@ -90,6 +97,9 @@ done
[ "$RS" = done ] || fail "the backup started through the API ended as '$RS' after $i ticks"
occ backup:list ci | grep " page " >/dev/null || fail "the snapshot of the ticked backup is not listed"
ok "without system cron: warning in backup:status and the API, backup finished by $i ticks of the open page"
# The Bin reads in parts, as the page does (what is in the parts: CatalogTest)
api "$API/targets/$TID/trash?offset=0" | php -r '$p = json_decode(stream_get_contents(STDIN), true); exit(is_int($p["total"] ?? null) && is_array($p["items"] ?? null) && count($p["items"]) <= $p["total"] ? 0 : 1);' || fail "the Bin API does not give a total and items"
ok "the Bin API reads in parts, with a total"
occ background:cron >/dev/null
$X php -f /var/www/html/cron.php >/dev/null 2>&1 || true
occ backup:status | grep "system cron" >/dev/null && fail "backup:status still warns after cron ran"