From b14b16c66cda21215b41316b3622a146a1c25cf4 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Fri, 6 Nov 2026 15:50:02 +0100 Subject: [PATCH] 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 --- CHANGELOG.md | 25 +++ PLAN.md | 8 + docs/DISASTER-RECOVERY.md | 16 +- lib/Backend/StorageBackend.php | 8 +- lib/Controller/AdminApiController.php | 16 +- lib/Db/DbDumper.php | 35 +++- lib/Job/BackupRun.php | 34 +++- lib/Job/TreeWalker.php | 13 +- lib/Repository/Repository.php | 152 +++++++++++++++--- lib/Repository/RestoreTargetException.php | 15 ++ .../DatabaseAlreadyRestoredException.php | 12 ++ lib/Service/RestoreFullService.php | 16 +- lib/Service/RestoreService.php | 26 ++- psalm-baseline.xml | 1 + src/api.js | 2 +- src/components/Bin.vue | 63 ++++++-- tests/Unit/Job/TreeWalkerTest.php | 22 +++ tests/Unit/Repository/CatalogTest.php | 20 +++ tests/Unit/Repository/EmptyDirsTest.php | 127 +++++++++++++++ tests/Unit/Repository/RestoreTargetTest.php | 119 ++++++++++++++ tests/integration/full-restore-roundtrip.sh | 12 +- tests/integration/roundtrip.sh | 14 +- 22 files changed, 693 insertions(+), 63 deletions(-) create mode 100644 lib/Repository/RestoreTargetException.php create mode 100644 lib/Service/DatabaseAlreadyRestoredException.php create mode 100644 tests/Unit/Repository/EmptyDirsTest.php create mode 100644 tests/Unit/Repository/RestoreTargetTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 6423596..8a60e48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,31 @@ Each heading starts with that ``, 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 diff --git a/PLAN.md b/PLAN.md index ef936d2..6f72fdf 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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. diff --git a/docs/DISASTER-RECOVERY.md b/docs/DISASTER-RECOVERY.md index cd517b6..f2deace 100644 --- a/docs/DISASTER-RECOVERY.md +++ b/docs/DISASTER-RECOVERY.md @@ -123,10 +123,12 @@ without `--install-apps`: occ maintenance:mode --off && occ backup:restore:full offsite --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. diff --git a/lib/Backend/StorageBackend.php b/lib/Backend/StorageBackend.php index 7126f15..ca57e72 100644 --- a/lib/Backend/StorageBackend.php +++ b/lib/Backend/StorageBackend.php @@ -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); } diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index 85a2683..72fda95 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -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); } diff --git a/lib/Db/DbDumper.php b/lib/Db/DbDumper.php index d8402e3..ef4bc72 100644 --- a/lib/Db/DbDumper.php +++ b/lib/Db/DbDumper.php @@ -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, ...} $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. diff --git a/lib/Job/BackupRun.php b/lib/Job/BackupRun.php index e7cc3f1..f15fd4f 100644 --- a/lib/Job/BackupRun.php +++ b/lib/Job/BackupRun.php @@ -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'; } diff --git a/lib/Job/TreeWalker.php b/lib/Job/TreeWalker.php index ce342b4..25366dc 100644 --- a/lib/Job/TreeWalker.php +++ b/lib/Job/TreeWalker.php @@ -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 "/" for files. + * by name, children right after their directory), yielding "/" 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 { diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index 44be374..947365d 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -44,6 +44,8 @@ final class Repository { private ?int $maxBytes = null; private Catalog $catalog; private PackReader $packReader; + /** @var array> 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 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 */ + 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>} + */ + 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']) diff --git a/lib/Repository/RestoreTargetException.php b/lib/Repository/RestoreTargetException.php new file mode 100644 index 0000000..fd5f9e5 --- /dev/null +++ b/lib/Repository/RestoreTargetException.php @@ -0,0 +1,15 @@ +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); } } diff --git a/lib/Service/RestoreService.php b/lib/Service/RestoreService.php index 8df6e0a..676e7f5 100644 --- a/lib/Service/RestoreService.php +++ b/lib/Service/RestoreService.php @@ -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 $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; diff --git a/psalm-baseline.xml b/psalm-baseline.xml index c138868..d6ad913 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -186,6 +186,7 @@ db]]> db]]> db]]> + db]]> diff --git a/src/api.js b/src/api.js index a81df4e..c5ad161 100644 --- a/src/api.js +++ b/src/api.js @@ -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 }), diff --git a/src/components/Bin.vue b/src/components/Bin.vue index 9b7bb76..b3d8abb 100644 --- a/src/components/Bin.vue +++ b/src/components/Bin.vue @@ -6,7 +6,17 @@ {{ error }} - +
+ + +

{{ t('ng_backup', 'This can take a while: every removed restore point is read from the location.') }}

+
{{ t('ng_backup', 'The bin is empty.') }}