Files
nextcloud-ng_backup/lib/Command/Verify.php
T
dillard d70a869070
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 (32) (push) Has been cancelled
CI / integration-user (33) (push) Has been cancelled
CI / integration-user (34) (push) Has been cancelled
CI / integration-user (35) (push) Has been cancelled
CI / integration-full-restore (push) Has been cancelled
Phase 2: verify per-user exports, refuse SMB without the native extension, matrix user CI
backup:verify accepts a user export path (users/<uid>/<export>): every blob its manifest references
is checked the same way as for a snapshot, shallow or --deep.

The SMB backend is refused at backup:target:add when the native smbclient PECL extension is not
loaded, instead of registering and then failing the first backup with "Seek failed": with only the
smbclient binary files_external falls back to a shell stream that cannot seek.

The per-user integration job now runs on Nextcloud 32-35, not just 35: it calls user_migration's
internal UserMigrationService, whose API may change between versions.
2026-10-06 03:47:02 +02:00

76 lines
3.2 KiB
PHP

<?php
declare(strict_types=1);
// SPDX-FileCopyrightText: 2026 Dillard Blom
// SPDX-License-Identifier: AGPL-3.0-or-later
namespace OCA\NgBackup\Command;
use OCA\NgBackup\Service\TargetService;
use OCA\NgBackup\Service\VerifyService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
final class Verify extends Command {
public function __construct(
private TargetService $targets,
private VerifyService $verify,
) {
parent::__construct();
}
protected function configure(): void {
$this->setName('backup:verify')
->setDescription('Checksum audit of a snapshot: every blob it needs must be in the (freshly re-checked) index and its pack present')
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
->addOption('deep', null, InputOption::VALUE_NONE, 'Also download and decrypt every blob (verifies its authentication tag and content hash, not just that it is listed); much slower')
->setHelp(<<<'HELP'
Without --deep this re-downloads and re-authenticates the index (never trusting the local cache)
and confirms every blob the snapshot needs is listed and its pack is present -- fast, no blob
content is read, so it cannot by itself catch a truncated or bit-rotted pack. With --deep every
blob is also downloaded and decrypted, which fails loudly on exactly that even if the index still
lists it -- the same verification an actual restore would do, run here deliberately instead of
waiting to find out during one.
Run this periodically (cron, picking a sample of recent snapshots) rather than only right before
you'd need to rely on a restore.
Examples:
occ backup:verify offsite 537bf7e9c1a4...
occ backup:verify offsite 537bf7e9c1a4... --deep
HELP);
}
protected function execute(InputInterface $input, OutputInterface $output): int {
try {
$target = $this->targets->get($input->getArgument('target'));
$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('<error>' . $e->getMessage() . '</error>');
return 1;
}
$output->writeln(sprintf('%d files, %d blobs checked%s.', $result['filesChecked'], $result['blobsChecked'],
$deep ? sprintf(' (%.1f MiB decrypted)', $result['bytesChecked'] / 1048576) : ''));
if ($result['missing'] !== []) {
$output->writeln('<error>' . count($result['missing']) . ' blob(s) missing from the index:</error> ' . implode(', ', $result['missing']));
}
if ($result['failed'] !== []) {
$output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $result['failed']));
}
if ($result['missing'] === [] && $result['failed'] === []) {
$output->writeln('<info>OK</info>');
return 0;
}
return 1;
}
}