Files
nextcloud-ng_backup/lib/UserMigration/RepositoryImportSource.php
T
dillard 6cbc5d79d4
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (push) Has been cancelled
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 "<uid>-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.
2026-10-05 17:36:46 +02:00

149 lines
4.5 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\UserMigration;
use OCA\NgBackup\Repository\Repository;
use OCP\Files\File;
use OCP\Files\Folder;
use OCP\UserMigration\IImportSource;
use OCP\UserMigration\UserMigrationException;
/** Import source for user_migration that reads a user export from the repository. */
final class RepositoryImportSource implements IImportSource {
private array $manifest;
public function __construct(
private Repository $repo,
string $manifestPath,
) {
$this->manifest = json_decode($repo->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
}
public function getFileContents(string $path): string {
$stream = $this->getFileAsStream($path);
$data = (string)stream_get_contents($stream);
fclose($stream);
return $data;
}
/** @return resource */
public function getFileAsStream(string $path) {
$entry = $this->entry($path, 'f');
// Phase 0: buffer in php://temp (memory up to 8 MiB, then a temp file for this one file).
$stream = fopen('php://temp/maxmemory:' . (8 * 1048576), 'w+b');
if ($stream === false) {
throw new UserMigrationException('Cannot buffer ' . $path);
}
foreach ($this->repo->readBlobs($entry['b']) as $data) {
for ($done = 0, $len = strlen($data); $done < $len; $done += $n) {
$n = fwrite($stream, $done === 0 ? $data : substr($data, $done));
if ($n === false || $n === 0) {
fclose($stream);
throw new UserMigrationException('Cannot buffer ' . $path);
}
}
}
rewind($stream);
return $stream;
}
public function getFolderListing(string $path): array {
$prefix = trim($path, '/');
$prefix = $prefix === '' ? '' : $prefix . '/';
$names = [];
/** @var string $p */
foreach (array_keys($this->manifest['entries']) as $p) {
if ($prefix === '' || str_starts_with($p, $prefix)) {
$rest = substr($p, strlen($prefix));
if ($rest !== '') {
// First path segment: also lists folders that only exist as a parent of a file.
$names[explode('/', $rest, 2)[0]] = true;
}
}
}
$names = array_keys($names);
sort($names);
return $names;
}
public function pathExists(string $path): bool {
$p = trim($path, '/');
if (isset($this->manifest['entries'][$p])) {
return true;
}
/** @var string $k */
foreach (array_keys($this->manifest['entries']) as $k) {
if (str_starts_with($k, $p . '/')) {
return true;
}
}
return false;
}
public function copyToFolder(Folder $destination, string $sourcePath): void {
foreach ($this->getFolderListing($sourcePath) as $name) {
$base = trim($sourcePath, '/');
$path = $base === '' ? $name : $base . '/' . $name;
$entry = $this->manifest['entries'][$path] ?? ['t' => 'd'];
if ($entry['t'] === 'd') {
$sub = $destination->nodeExists($name) ? $destination->get($name) : $destination->newFolder($name);
if (!$sub instanceof Folder) {
throw new UserMigrationException("$path exists and is not a folder");
}
$this->copyToFolder($sub, $path);
} else {
$stream = $this->getFileAsStream($path);
try {
if ($destination->nodeExists($name)) {
$existing = $destination->get($name);
if (!$existing instanceof File) {
throw new UserMigrationException("$path exists and is not a file");
}
$existing->putContent($stream);
} else {
$destination->newFile($name, $stream);
}
} finally {
// 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);
}
}
}
}
}
public function getMigratorVersions(): array {
return $this->manifest['versions'];
}
public function getMigratorVersion(string $migrator): ?int {
return $this->manifest['versions'][$migrator] ?? null;
}
public function getOriginalUid(): string {
return $this->manifest['uid'];
}
public function close(): void {
}
/** @return array{t:string, b:list<string>, s:int} */
private function entry(string $path, string $type): array {
$entry = $this->manifest['entries'][trim($path, '/')] ?? null;
if ($entry === null || $entry['t'] !== $type) {
throw new UserMigrationException('Path not found in backup: ' . $path);
}
return $entry;
}
}