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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'])
|
||||
|
||||
@@ -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 {
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user