diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a216970..44d9a35 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -63,19 +63,24 @@ jobs:
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.
+ # an optional dependency installed from the app store. It calls user_migration's internal
+ # UserMigrationService, whose API can change between Nextcloud versions, so this runs on every
+ # supported version (not the database, which the main integration job already covers).
runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ nextcloud: ['32', '33', '34', '35']
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
+ -v "$PWD:/var/www/html/custom_apps/ng_backup:ro" nextcloud:${{ matrix.nextcloud }}-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
+ - name: Per-user backup/restore round trip on Nextcloud ${{ matrix.nextcloud }}
run: tests/integration/user-roundtrip.sh "docker exec -u www-data ngbu" /var/www/html/data
integration-full-restore:
diff --git a/lib/Command/Verify.php b/lib/Command/Verify.php
index bae03fb..a9d1a7c 100644
--- a/lib/Command/Verify.php
+++ b/lib/Command/Verify.php
@@ -49,13 +49,17 @@ HELP);
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$target = $this->targets->get($input->getArgument('target'));
- $result = $this->verify->verify($target, (string)$input->getArgument('snapshot'), (bool)$input->getOption('deep'));
+ $ref = (string)$input->getArgument('snapshot');
+ $deep = (bool)$input->getOption('deep');
+ $result = str_starts_with($ref, 'users/')
+ ? $this->verify->verifyUserExport($target, $ref, $deep)
+ : $this->verify->verify($target, $ref, $deep);
} catch (\Throwable $e) {
$output->writeln('' . $e->getMessage() . '');
return 1;
}
$output->writeln(sprintf('%d files, %d blobs checked%s.', $result['filesChecked'], $result['blobsChecked'],
- $input->getOption('deep') ? sprintf(' (%.1f MiB decrypted)', $result['bytesChecked'] / 1048576) : ''));
+ $deep ? sprintf(' (%.1f MiB decrypted)', $result['bytesChecked'] / 1048576) : ''));
if ($result['missing'] !== []) {
$output->writeln('' . count($result['missing']) . ' blob(s) missing from the index: ' . implode(', ', $result['missing']));
}
diff --git a/lib/Repository/Repository.php b/lib/Repository/Repository.php
index 2d83758..1721308 100644
--- a/lib/Repository/Repository.php
+++ b/lib/Repository/Repository.php
@@ -716,6 +716,40 @@ final class Repository {
return $stats;
}
+ /**
+ * Same checks as verify(), for a user export: every blob its manifest references must be in
+ * the freshly re-authenticated index with its pack present; $deep also decrypts each.
+ *
+ * @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list, failed:list}
+ */
+ public function verifyUserExport(string $manifestPath, bool $deep = false, ?callable $heartbeat = null): array {
+ if (!$this->isRecordedUserExport($manifestPath)) {
+ throw new RepositoryException("$manifestPath is not a recorded user export for this location");
+ }
+ $heartbeat ??= static function (): void {
+ };
+ $this->index->load(true);
+ $stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []];
+ $seen = [];
+ $packSeen = [];
+ $manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
+ foreach ($manifest['entries'] as $entry) {
+ if (($entry['t'] ?? '') !== 'f') {
+ continue;
+ }
+ $stats['filesChecked']++;
+ foreach ($entry['b'] ?? [] as $id) {
+ if (isset($seen[$id])) {
+ continue;
+ }
+ $seen[$id] = true;
+ $this->verifyBlob($id, $deep, $stats, $packSeen);
+ $heartbeat();
+ }
+ }
+ return $stats;
+ }
+
/** @param array $packSeen */
private function verifyBlob(string $id, bool $deep, array &$stats, array &$packSeen): void {
if (!$this->index->has($id)) {
diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php
index 3878feb..dfa8735 100644
--- a/lib/Service/TargetService.php
+++ b/lib/Service/TargetService.php
@@ -53,6 +53,12 @@ final class TargetService {
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
}
+ // Without the native extension files_external falls back to the smbclient binary, which
+ // registers fine but cannot seek: the first backup then fails with "Seek failed". Refuse
+ // up front instead of letting the admin find out from a failed run.
+ if ($backend === 'smb' && !extension_loaded('smbclient')) {
+ throw new \InvalidArgumentException('The SMB backend needs the PHP smbclient extension (pecl install smbclient); the smbclient binary alone cannot seek and every backup would fail');
+ }
try {
$this->mapper->findByName($name);
throw new \InvalidArgumentException("A target named '$name' already exists");
diff --git a/lib/Service/VerifyService.php b/lib/Service/VerifyService.php
index ef3d893..bef188e 100644
--- a/lib/Service/VerifyService.php
+++ b/lib/Service/VerifyService.php
@@ -20,9 +20,18 @@ final class VerifyService {
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list, failed:list} */
public function verify(Target $target, string $snapshotId, bool $deep = false): array {
+ return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
+ }
+
+ /** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list, failed:list} */
+ public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array {
+ return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
+ }
+
+ private function run(Target $target, bool $deep, callable $fn): array {
$ttl = $deep ? 600 : 300;
try {
- return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $snapshotId, $deep, $ttl) {
+ return $this->leases->with(PruneService::lockKey($target), LeaseService::SHARED, $ttl, function (callable $refresh) use ($target, $deep, $ttl, $fn): array {
$last = time();
$heartbeat = function () use ($refresh, $ttl, &$last): void {
if (time() - $last >= intdiv($ttl, 2)) {
@@ -30,7 +39,7 @@ final class VerifyService {
$last = time();
}
};
- return $this->targets->repository($target)->verify($snapshotId, $deep, $heartbeat);
+ return $fn($this->targets->repository($target), $heartbeat);
});
} catch (LockedException) {
throw new \RuntimeException('A cleanup of ' . $target->getName() . ' is in progress; try again later');