From 6cbc5d79d42c412124a4d0a2ce476818b2ee8c51 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Mon, 5 Oct 2026 17:13:49 +0200 Subject: [PATCH] Phase 2: per-user backup and restore via user_migration Wires the existing RepositoryExportDestination/RepositoryImportSource (see lib/UserMigration/) into user_migration's own UserMigrationService, resolved on demand through a new UserMigrationServiceLocator since the app is an optional dependency (PLAN.md 3.8) -- without it, only this feature is unavailable, the full backup still works. New: UserBackupService::backupUser() (occ backup:backup:user) streams a user_migration export straight into the repository, under the same shared location lock as a regular backup. UserRestoreService::restoreUser() (occ backup:restore:user) never imports over a live account: --mode=replace safety-exports the current state first, then deletes and recreates the account from the backup under the same uid; --mode=as-backup restores into a new "-bak" account instead, since Nextcloud cannot rename a uid. The catalog now also tracks user export manifests (Repository::recordUserExport/ listUserExports/forgetUserExport/isRecordedUserExport), rollback-protected like a snapshot: listUserExports() verifies each one is still present on the location, and restore only ever reads a manifest the verified catalog actually recorded, never an arbitrary caller-supplied path. prune()'s existing raw users/ scan for blob retention is unchanged. A new short exclusive "catalog" lock (PruneService::catalogLockKey()) serializes the catalog write itself, so two concurrent per-user backups (or one alongside a full backup) can no longer race to write the same next generation and silently drop one of them -- the broader per-target shared lock only ever protected against a concurrent cleanup, not against other shared holders. Two real bugs found by testing this against a live Nextcloud instance, not by unit tests (RepositoryExportDestination/RepositoryImportSource implement OCP interfaces that plain PHPUnit can't load, see tests/Unit/Repository/UserExportTest.php's docblock; the new tests/integration/user-roundtrip.sh covers the real round trip instead): - RepositoryImportSource::copyToFolder() double-closed the stream it handed to putContent()/newFile() (both already close it themselves), which throws a TypeError on PHP 8 and broke every restored file with content. - A replace-mode restore failed on NotPermittedException: IRootFolder::getUserFolder() caches a Folder per uid for the process lifetime, so after delete+recreate of the same uid the import still got the deleted account's (now storage-less) folder. Fixed by dispatching FilesystemTornDownEvent, which Root already listens for to clear exactly that cache -- no internal OC\ classes needed. Local review (CodeRabbit + Codex) against this diff found three more real issues, all fixed: the catalog write race above, a failed as-backup import leaving a broken stub account behind (now deleted on failure), and an export stream leak on a read/backend failure in RepositoryExportDestination::copyFolder() (now closed in a finally). Deliberately left out: wiring per-user export into the automatic backup schedule (on-demand via occ only, for now) and retention/pruning of old user exports -- both open design questions, not yet decided. The lease covering a single export/import still has a fixed TTL (3600s) with no mid-call refresh, since UserMigrationService exposes no progress; a resumable, step-based per-user backup (like the main BackupService run) would remove that cap but is a bigger change than this pass. --- .github/workflows/ci.yml | 17 +++ appinfo/info.xml | 2 + lib/Command/BackupUser.php | 59 ++++++++ lib/Command/RestoreUser.php | 57 ++++++++ lib/Repository/Catalog.php | 11 +- lib/Repository/Repository.php | 47 +++++- lib/Service/PruneService.php | 13 ++ lib/Service/UserBackupService.php | 69 +++++++++ lib/Service/UserRestoreService.php | 137 ++++++++++++++++++ .../RepositoryExportDestination.php | 7 +- lib/UserMigration/RepositoryImportSource.php | 9 +- .../UserMigrationServiceLocator.php | 35 +++++ psalm.xml | 9 ++ tests/Unit/Repository/UserExportTest.php | 109 ++++++++++++++ tests/integration/user-roundtrip.sh | 59 ++++++++ 15 files changed, 634 insertions(+), 6 deletions(-) create mode 100644 lib/Command/BackupUser.php create mode 100644 lib/Command/RestoreUser.php create mode 100644 lib/Service/UserBackupService.php create mode 100644 lib/Service/UserRestoreService.php create mode 100644 lib/UserMigration/UserMigrationServiceLocator.php create mode 100644 tests/Unit/Repository/UserExportTest.php create mode 100755 tests/integration/user-roundtrip.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a8591bd..3a37530 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,3 +60,20 @@ jobs: - uses: actions/checkout@v4 - name: Round trip against S3 and SFTP run: tests/integration/targets-matrix.sh + + integration-user: + # Per-user backup/restore via user_migration (occ backup:backup:user / backup:restore:user), + # an optional dependency installed from the app store. Does not depend on the Nextcloud + # version or database beyond what the main integration job already covers, so this runs once. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Set up a fresh Nextcloud instance + run: | + docker run -d --name ngbu -e SQLITE_DATABASE=nextcloud -e NEXTCLOUD_ADMIN_USER=admin \ + -e NEXTCLOUD_ADMIN_PASSWORD=admin-user-roundtrip -e NEXTCLOUD_TRUSTED_DOMAINS=localhost \ + -v "$PWD:/var/www/html/custom_apps/ng_backup:ro" nextcloud:35-apache + for i in $(seq 1 90); do docker exec -u www-data ngbu php occ status 2>/dev/null | grep -q "installed: true" && break; sleep 4; done + docker exec ngbu chown www-data:www-data /var/www/html/custom_apps + - name: Per-user backup/restore round trip + run: tests/integration/user-roundtrip.sh "docker exec -u www-data ngbu" /var/www/html/data diff --git a/appinfo/info.xml b/appinfo/info.xml index 2314101..348c967 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -47,6 +47,8 @@ relying on this for anything.]]> OCA\NgBackup\Command\KeySlotAdd OCA\NgBackup\Command\KeySlotReplace OCA\NgBackup\Command\KeySlotRemove + OCA\NgBackup\Command\BackupUser + OCA\NgBackup\Command\RestoreUser OCA\NgBackup\Settings\Admin diff --git a/lib/Command/BackupUser.php b/lib/Command/BackupUser.php new file mode 100644 index 0000000..75f0d35 --- /dev/null +++ b/lib/Command/BackupUser.php @@ -0,0 +1,59 @@ +setName('backup:backup:user') + ->setDescription('Back up one user via user_migration (account, settings, files and everything else a migrator covers)') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->addArgument('user', InputArgument::REQUIRED, 'User id') + ->setHelp(<<<'HELP' +Streams a user_migration export straight into the encrypted repository: no zip, no copy in the +user's own storage. Shares data with the regular backup; this needs the user_migration app +enabled. The manifest path this prints is what occ backup:restore:user expects. + +Example: + occ backup:backup:user offsite alice +HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + $uid = (string)$input->getArgument('user'); + $user = $this->userManager->get($uid); + if ($user === null) { + $output->writeln("No such user: $uid"); + return 1; + } + try { + $target = $this->targets->get($input->getArgument('target')); + $manifest = $this->userBackup->backupUser($target, $user); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln("Backed up $uid to $manifest."); + return 0; + } +} diff --git a/lib/Command/RestoreUser.php b/lib/Command/RestoreUser.php new file mode 100644 index 0000000..90e2a95 --- /dev/null +++ b/lib/Command/RestoreUser.php @@ -0,0 +1,57 @@ +setName('backup:restore:user') + ->setDescription('Restore a user from a user_migration export (see backup:backup:user)') + ->addArgument('target', InputArgument::REQUIRED, 'Location name or id') + ->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:backup:user (users//)') + ->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE) + ->setHelp(<<<'HELP' +Never imports over a live account. Modes: + replace (default) safety-exports the current account first, then deletes and recreates it + fresh from the backup, under the same user id + as-backup restores into a new account "-bak" (or "-bak-2", ...) instead, so an admin can + compare and transfer by hand; Nextcloud cannot rename a user id, so the backup gets + the new name, never the existing account + +Example: + occ backup:restore:user offsite users/alice/20261005T120000Z-ab12cd34 + occ backup:restore:user offsite users/alice/20261005T120000Z-ab12cd34 --mode=as-backup +HELP); + } + + protected function execute(InputInterface $input, OutputInterface $output): int { + try { + $target = $this->targets->get($input->getArgument('target')); + $user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), (string)$input->getOption('mode')); + } catch (\Throwable $e) { + $output->writeln('' . $e->getMessage() . ''); + return 1; + } + $output->writeln('Restored ' . $user->getUID() . '.'); + return 0; + } +} diff --git a/lib/Repository/Catalog.php b/lib/Repository/Catalog.php index 56ec8fd..44fb6c4 100644 --- a/lib/Repository/Catalog.php +++ b/lib/Repository/Catalog.php @@ -28,6 +28,8 @@ final class Catalog { public array $snapshots = []; /** @var array */ public array $trash = []; + /** @var array manifest path => true, for a per-user export (users//) */ + public array $userExports = []; public string $hash = ''; public function __construct( @@ -39,7 +41,7 @@ final class Catalog { } /** Load and verify the newest catalog; create the first one for repositories that have none. */ - public function load(callable $listSnapshots, callable $listTrash): void { + public function load(callable $listSnapshots, callable $listTrash, callable $listUserExports): void { $gens = $this->generations(); if ($gens === []) { // First use (new or pre-catalog repository): adopt the current state as generation 1. @@ -49,6 +51,7 @@ final class Catalog { } $this->snapshots = array_fill_keys($listSnapshots(), true); $this->trash = array_fill_keys($listTrash(), true); + $this->userExports = array_fill_keys($listUserExports(), true); $this->write(); return; } @@ -76,6 +79,9 @@ final class Catalog { $this->hash = $hash; $this->snapshots = array_fill_keys($data['snapshots'], true); $this->trash = array_fill_keys($data['trash'], true); + // Catalogs written before user exports existed have no such key: default to empty rather + // than adopting the location's current users/ listing (that would skip verification). + $this->userExports = array_fill_keys($data['userExports'] ?? [], true); $this->anchor?->set($this->repositoryId, $this->gen, $this->hash); } @@ -83,7 +89,8 @@ final class Catalog { public function write(): void { $next = $this->gen + 1; $plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash, - 'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash)], JSON_THROW_ON_ERROR); + 'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash), + 'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR); $encrypted = $this->cipher->encryptString($plain, $this->ad($next)); $stream = fopen('php://memory', 'w+b'); fwrite($stream, $encrypted); diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php index f575fa5..59d83bf 100644 --- a/lib/Repository/Repository.php +++ b/lib/Repository/Repository.php @@ -91,7 +91,8 @@ final class Repository { $this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor); $this->catalog->load( fn () => array_map('basename', $this->backend->list('snapshots')), - fn () => array_map('basename', $this->backend->list('trash/info'))); + fn () => array_map('basename', $this->backend->list('trash/info')), + fn () => $this->backend->list('users')); } /** Refuse uploads that would make the stored data larger than this (null = no limit). */ @@ -374,6 +375,50 @@ final class Repository { $this->backend->delete('trash/info/' . $snapshotId); } + /** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */ + public function recordUserExport(string $manifestPath): void { + $this->catalog->userExports[$manifestPath] = true; + $this->catalog->write(); + } + + /** + * User export manifests according to the verified catalog, newest first. A manifest the + * catalog lists but the location no longer has means it was deleted or hidden behind NG + * Backup's back, same as a missing snapshot (see listSnapshots()). + * + * @return list + */ + public function listUserExports(?string $uid = null): array { + $prefix = $uid !== null ? 'users/' . $uid . '/' : 'users/'; + $out = []; + foreach (array_keys($this->catalog->userExports) as $path) { + if (!str_starts_with($path, $prefix)) { + continue; + } + if (!$this->backend->exists($path)) { + throw new RepositoryException("User export $path is missing on the location (deleted or hidden outside NG Backup)"); + } + $out[] = $path; + } + sort($out, SORT_STRING); + return array_reverse($out); + } + + /** Whether $manifestPath is a user export the verified catalog actually recorded. */ + public function isRecordedUserExport(string $manifestPath): bool { + return isset($this->catalog->userExports[$manifestPath]); + } + + /** Permanently delete a user export. Its blobs are freed by the next prune() like any other. */ + public function forgetUserExport(string $manifestPath): void { + if (!isset($this->catalog->userExports[$manifestPath])) { + throw new RepositoryException("$manifestPath is not a recorded user export"); + } + $this->backend->delete($manifestPath); + unset($this->catalog->userExports[$manifestPath]); + $this->catalog->write(); + } + /** @return array */ public function trash(): array { $out = []; diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php index 97b2d6e..2c78399 100644 --- a/lib/Service/PruneService.php +++ b/lib/Service/PruneService.php @@ -98,6 +98,19 @@ final class PruneService { return 'ng_backup/target/' . $target->getId(); } + /** + * A separate, short-lived exclusive lock serializing writes to the catalog itself. The + * per-target lock above only keeps readers/writers out while a cleanup runs; it does not + * stop two SHARED holders (e.g. two concurrent per-user backups, or one alongside a full + * backup) from both reading the same catalog generation and racing to write the next one, + * silently dropping whichever's change loses. The full-backup path avoids this today only + * because a Run row makes concurrent runs for the same target mutually exclusive; per-user + * backup/restore has no such row, so it takes this lock around the catalog write itself. + */ + public static function catalogLockKey(Target $target): string { + return self::lockKey($target) . '/catalog'; + } + public function policy(): RetentionPolicy { return RetentionPolicy::fromArray(json_decode($this->appConfig->getValueString(Application::APP_ID, 'retention', '{}'), true) ?: []); } diff --git a/lib/Service/UserBackupService.php b/lib/Service/UserBackupService.php new file mode 100644 index 0000000..e4eb877 --- /dev/null +++ b/lib/Service/UserBackupService.php @@ -0,0 +1,69 @@ +/. + * + * user_migration is an optional dependency (unlike files_external): without it, only this + * per-user backup/restore is unavailable, the full backup still works. + */ +final class UserBackupService { + public function __construct( + private TargetService $targets, + private LeaseService $leases, + private UserMigrationServiceLocator $locator, + ) { + } + + /** @throws UserMigrationException when user_migration is not enabled, or the export itself fails */ + public function backupUser(Target $target, IUser $user): string { + $service = $this->locator->get(); + return $this->withSharedLock($target, function () use ($target, $user, $service) { + $repo = $this->targets->repository($target); + $dest = new RepositoryExportDestination($repo, $user->getUID()); + $service->export($dest, $user); + // A short exclusive lock around the catalog write only: the shared lock above + // allows another concurrent backup (full or per-user) to be reading the same + // catalog generation at the same time, and both writing the next one would let + // one silently overwrite the other's addition. + $this->withCatalogLock($target, fn () => $repo->recordUserExport($dest->manifestPath())); + return $dest->manifestPath(); + }); + } + + private function withSharedLock(Target $target, callable $fn): mixed { + try { + // UserMigrationService::export() exposes no progress, so unlike + // RestoreService::restoreUserFiles() this lease cannot be refreshed mid-call; a very + // large account could in theory outrun this TTL. Resumable, step-based per-user + // backup (like the main BackupService run) is a possible future improvement. + return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn); + } catch (LockedException) { + throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later'); + } + } + + private function withCatalogLock(Target $target, callable $fn): mixed { + try { + return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 30, $fn); + } catch (LockedException) { + throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again'); + } + } +} diff --git a/lib/Service/UserRestoreService.php b/lib/Service/UserRestoreService.php new file mode 100644 index 0000000..68cf24a --- /dev/null +++ b/lib/Service/UserRestoreService.php @@ -0,0 +1,137 @@ +-bak" (or "-bak-2", ...) instead, + * so an admin can compare and transfer by hand. Nextcloud cannot rename a uid, so the *backup* + * gets the new name, never the existing account. + */ +final class UserRestoreService { + public const MODE_REPLACE = 'replace'; + public const MODE_AS_BACKUP = 'as-backup'; + + public function __construct( + private TargetService $targets, + private LeaseService $leases, + private UserMigrationServiceLocator $locator, + private IUserManager $userManager, + private ISecureRandom $random, + private IEventDispatcher $events, + ) { + } + + /** @throws UserMigrationException when user_migration is not enabled, or the restore itself fails */ + public function restoreUser(Target $target, string $manifestPath, string $mode = self::MODE_REPLACE): IUser { + $service = $this->locator->get(); + return $this->withSharedLock($target, function () use ($target, $manifestPath, $mode, $service): IUser { + $repo = $this->targets->repository($target); + // Only ever read a manifest the verified catalog actually recorded: a path alone + // (even one this app generated before) proves nothing about it still being current + // or genuine once the catalog's rollback protection is supposed to vouch for it. + if (!$repo->isRecordedUserExport($manifestPath)) { + throw new \InvalidArgumentException("$manifestPath is not a recorded user export for this location"); + } + $source = new RepositoryImportSource($repo, $manifestPath); + $originalUid = $source->getOriginalUid(); + + if ($mode === self::MODE_AS_BACKUP) { + $backupUid = $this->freeBackupUid($originalUid); + $user = $this->userManager->createUser($backupUid, $this->random->generate(32)); + if ($user === false) { + throw new \RuntimeException("Could not create the account $backupUid"); + } + try { + return $service->import($source, $user); + } catch (\Throwable $e) { + // A partially-imported, never-used account left behind is strictly worse + // than no account at all; the original source account was never touched. + $user->delete(); + throw $e; + } + } + + if ($mode !== self::MODE_REPLACE) { + throw new \InvalidArgumentException("Unknown restore mode: $mode"); + } + $existing = $this->userManager->get($originalUid); + $safetyManifestPath = null; + if ($existing !== null) { + // Never import over a live account: export its current state first, so nothing + // is lost if the restore turns out to be the wrong choice. + $safety = new RepositoryExportDestination($repo, $originalUid); + $service->export($safety, $existing); + $safetyManifestPath = $safety->manifestPath(); + // Same race as UserBackupService::backupUser(): serialize the catalog write + // itself against any other concurrent backup/restore for this target. + $this->withCatalogLock($target, fn () => $repo->recordUserExport($safetyManifestPath)); + if (!$existing->delete()) { + throw new \RuntimeException("Could not delete the existing account $originalUid before restoring; its safety export is at $safetyManifestPath"); + } + // IRootFolder caches a Folder per uid for the lifetime of this process; without + // this it would still hand the just-deleted account's (now storage-less) folder + // to the import below, and every write would fail. + $this->events->dispatchTyped(new FilesystemTornDownEvent()); + } + try { + return $service->import($source, null); + } catch (\Throwable $e) { + // The original account is already gone at this point (or never existed); a + // failure here can only be reported, not undone in place. Its safety export + // (if any) is the recovery path, already recorded above. + throw new UserMigrationException( + "Could not restore $originalUid" . ($safetyManifestPath !== null ? "; its safety export is at $safetyManifestPath" : '') . ': ' . $e->getMessage(), + 0, + $e, + ); + } + }); + } + + private function freeBackupUid(string $uid): string { + $candidate = $uid . '-bak'; + for ($n = 2; $this->userManager->userExists($candidate); $n++) { + $candidate = $uid . '-bak-' . $n; + } + return $candidate; + } + + private function withSharedLock(Target $target, callable $fn): mixed { + try { + // See UserBackupService::withSharedLock(): UserMigrationService exposes no progress, + // so this lease cannot be refreshed mid-call. + return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, 3600, $fn); + } catch (LockedException) { + throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later'); + } + } + + private function withCatalogLock(Target $target, callable $fn): mixed { + try { + return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 30, $fn); + } catch (LockedException) { + throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again'); + } + } +} diff --git a/lib/UserMigration/RepositoryExportDestination.php b/lib/UserMigration/RepositoryExportDestination.php index 3eb1ce1..8eec0d1 100644 --- a/lib/UserMigration/RepositoryExportDestination.php +++ b/lib/UserMigration/RepositoryExportDestination.php @@ -65,8 +65,11 @@ final class RepositoryExportDestination implements IExportDestination { if ($stream === false) { throw new \RuntimeException('Cannot read ' . $node->getPath()); } - $this->addFileAsStream($target, $stream); - fclose($stream); + try { + $this->addFileAsStream($target, $stream); + } finally { + fclose($stream); + } } } } diff --git a/lib/UserMigration/RepositoryImportSource.php b/lib/UserMigration/RepositoryImportSource.php index a8b4898..84a6acc 100644 --- a/lib/UserMigration/RepositoryImportSource.php +++ b/lib/UserMigration/RepositoryImportSource.php @@ -109,7 +109,14 @@ final class RepositoryImportSource implements IImportSource { $destination->newFile($name, $stream); } } finally { - fclose($stream); + // putContent()/newFile() already close the stream once they've consumed it: + // despite the @return resource docblock above, $stream is no longer a + // resource by the time we get here (verified against a real storage; a + // double fclose() throws a TypeError on PHP 8). + /** @psalm-suppress RedundantConditionGivenDocblockType */ + if (is_resource($stream)) { + fclose($stream); + } } } } diff --git a/lib/UserMigration/UserMigrationServiceLocator.php b/lib/UserMigration/UserMigrationServiceLocator.php new file mode 100644 index 0000000..aa95807 --- /dev/null +++ b/lib/UserMigration/UserMigrationServiceLocator.php @@ -0,0 +1,35 @@ +appManager->isEnabledForAnyone('user_migration')) { + throw new UserMigrationException('The user_migration app is not enabled; per-user backup/restore is unavailable'); + } + $this->appManager->loadApp('user_migration'); + return Server::get(\OCA\UserMigration\Service\UserMigrationService::class); + } +} diff --git a/psalm.xml b/psalm.xml index d694c57..7561477 100644 --- a/psalm.xml +++ b/psalm.xml @@ -31,8 +31,17 @@ + + + + + + + + diff --git a/tests/Unit/Repository/UserExportTest.php b/tests/Unit/Repository/UserExportTest.php new file mode 100644 index 0000000..8750dc6 --- /dev/null +++ b/tests/Unit/Repository/UserExportTest.php @@ -0,0 +1,109 @@ +root = $this->tempDir(); + $this->backend = new LocalBackend($this->root); + $this->keys = KeyRing::generate(); + $this->anchor = new class implements ICatalogAnchor { + public array $data = []; + public function get(string $r): ?array { return $this->data[$r] ?? null; } + public function set(string $r, int $g, string $h): void { $this->data[$r] = ['gen' => $g, 'hash' => $h]; } + }; + } + + private function open(): Repository { + return Repository::openWithKey($this->backend, $this->keys, null, $this->anchor); + } + + /** Stands in for an export destination's close(): a manifest object plus a catalog record. */ + private function putManifest(Repository $repo, string $path): void { + $repo->putObject($path, json_encode(['uid' => 'alice'])); + $repo->recordUserExport($path); + } + + public function testRecordAndListSurviveAReopen(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); + + $again = $this->open(); + $this->assertSame(['users/alice/20261005T120000Z-aaaa0001'], $again->listUserExports()); + $this->assertSame(['users/alice/20261005T120000Z-aaaa0001'], $again->listUserExports('alice')); + $this->assertSame([], $again->listUserExports('bob')); + } + + public function testListIsNewestFirst(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $this->putManifest($repo, 'users/alice/20261005T120000Z-aaaa0001'); + $this->putManifest($repo, 'users/alice/20261006T120000Z-aaaa0002'); + + $this->assertSame( + ['users/alice/20261006T120000Z-aaaa0002', 'users/alice/20261005T120000Z-aaaa0001'], + $repo->listUserExports('alice'), + ); + } + + public function testForgetRemovesItFromTheCatalogAndLocation(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $path = 'users/alice/20261005T120000Z-aaaa0001'; + $this->putManifest($repo, $path); + + $repo->forgetUserExport($path); + + $this->assertSame([], $repo->listUserExports()); + $this->assertFalse($this->backend->exists($path)); + } + + public function testForgetRejectsAPathNotInTheCatalog(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $this->expectException(RepositoryException::class); + $repo->forgetUserExport('users/nobody/not-a-real-export'); + } + + public function testIsRecordedUserExport(): void { + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $path = 'users/alice/20261005T120000Z-aaaa0001'; + $this->putManifest($repo, $path); + + $this->assertTrue($repo->isRecordedUserExport($path)); + $this->assertFalse($repo->isRecordedUserExport('users/alice/not-the-real-one')); + } + + public function testListingDetectsAManifestRemovedBehindItsBack(): void { + // A catalog-listed export whose object disappeared from the location (deleted or + // hidden outside NG Backup) must be detected, the same as a missing snapshot. + $repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'), $this->anchor); + $path = 'users/alice/20261005T120000Z-aaaa0001'; + $this->putManifest($repo, $path); + $this->backend->delete($path); + + $this->expectException(RepositoryException::class); + $this->expectExceptionMessage('missing on the location'); + $repo->listUserExports(); + } +} diff --git a/tests/integration/user-roundtrip.sh b/tests/integration/user-roundtrip.sh new file mode 100755 index 0000000..69d3197 --- /dev/null +++ b/tests/integration/user-roundtrip.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Per-user backup/restore round trip via user_migration (optional dependency, see PLAN.md 3.8): +# occ backup:backup:user / occ backup:restore:user --mode=as-backup|replace. +# Usage: user-roundtrip.sh "" [target-args] +# e.g. user-roundtrip.sh "docker exec -u www-data ngb-nc35-pgsql" /var/www/html/data +set -euo pipefail +X="$1"; DATA="$2" +TARGET_ARGS="${3:-local -a null::null -o datadir=/tmp/ngb-urt-target}" +occ() { $X php occ "$@"; } +fail() { echo "FAIL: $*"; exit 1; } +ok() { echo "ok $*"; } +U=ngbutest-$RANDOM + +occ app:enable ng_backup >/dev/null +if ! occ app:install user_migration >/dev/null 2>&1 && ! occ app:enable user_migration >/dev/null 2>&1; then + echo "SKIP: user_migration is not available (no network, or the app store is unreachable) -- per-user backup/restore not tested" + exit 0 +fi +sleep 8 # APCu app config cache + +$X sh -c "printf 'user roundtrip passphrase 2026\n' > /tmp/ngb-urt-pass && mkdir -p /tmp/ngb-urt-target" +occ backup:key:init --passphrase-file=/tmp/ngb-urt-pass --label=CI --delete-delay-days=7 >/dev/null || fail "key init" +CODE=$(occ backup:key:kit | php -r 'echo json_decode(stream_get_contents(STDIN), true)["confirmation_code"];') +occ backup:key:confirm -u admin --code="$CODE" --confirm="Yes, I confirm" >/dev/null || fail "confirm" +ok "key, kit code and confirmation" + +occ backup:target:add ci $TARGET_ARGS --path=repo >/dev/null || fail "target add" +ok "location added" + +$X sh -c "OC_PASS='User-roundtrip-pw-2026' php occ user:add --password-from-env $U >/dev/null" +$X sh -c "mkdir -p $DATA/$U/files/Docs && for i in 1 2 3; do head -c 100000 /dev/urandom > $DATA/$U/files/Docs/f\$i.bin; done" +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") + +MANIFEST=$(occ backup:backup:user ci "$U" | sed -n 's/.* to \(.*\)\.$/\1/p') +[ -n "$MANIFEST" ] || fail "backup:backup:user printed no manifest path" +ok "backed up $U to $MANIFEST" + +# as-backup: restore into -bak; the original account must stay untouched. +occ backup:restore:user ci "$MANIFEST" --mode=as-backup >/dev/null || fail "restore as-backup" +occ files:scan "$U-bak" >/dev/null +SUM_BAK=$($X sh -c "cd $DATA/$U-bak/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum") +[ "$SUM_BEFORE" = "$SUM_BAK" ] || fail "the -bak account's files differ from the original" +occ user:delete "$U-bak" >/dev/null +$X sh -c "[ -d '$DATA/$U/files/Docs' ]" || fail "the original account should be untouched by an as-backup restore" +ok "as-backup restores into -bak, byte-identical, original account untouched" + +# replace: damage the account, then restore it in place under the same uid. +$X sh -c "rm '$DATA/$U/files/Docs/f1.bin' && echo damaged > '$DATA/$U/files/Docs/f1.bin'" +occ files:scan "$U" >/dev/null +occ backup:restore:user ci "$MANIFEST" --mode=replace >/dev/null || fail "restore replace" +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 a replace restore" +ok "replace restores the original content under the same uid, byte-identical" + +occ user:delete "$U" >/dev/null +$X sh -c "rm -rf /tmp/ngb-urt-target /tmp/ngb-urt-pass" +echo "USER ROUNDTRIP PASS"