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"