Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b144f20453 | |||
| 50acd5dc0e | |||
| 2ab4b2e7d5 | |||
| 9cefea019d | |||
| b42acb41c5 | |||
| 4a13f8e791 |
+18
-1
@@ -5,7 +5,7 @@
|
|||||||
<name>NG Backup</name>
|
<name>NG Backup</name>
|
||||||
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
<summary>Encrypted, incremental backups of your Nextcloud with restore from the web interface</summary>
|
||||||
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
<description><![CDATA[Work in progress, not ready for use.]]></description>
|
||||||
<version>0.0.1</version>
|
<version>0.0.4</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>Dillard Blom</author>
|
<author>Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
@@ -16,4 +16,21 @@
|
|||||||
<lib>sodium</lib>
|
<lib>sodium</lib>
|
||||||
<nextcloud min-version="32" max-version="35"/>
|
<nextcloud min-version="32" max-version="35"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
<background-jobs>
|
||||||
|
<job>OCA\NgBackup\BackgroundJob\BackupJob</job>
|
||||||
|
</background-jobs>
|
||||||
|
<commands>
|
||||||
|
<command>OCA\NgBackup\Command\KeyInit</command>
|
||||||
|
<command>OCA\NgBackup\Command\KeyKit</command>
|
||||||
|
<command>OCA\NgBackup\Command\KeyConfirm</command>
|
||||||
|
<command>OCA\NgBackup\Command\TargetBackends</command>
|
||||||
|
<command>OCA\NgBackup\Command\TargetAdd</command>
|
||||||
|
<command>OCA\NgBackup\Command\TargetList</command>
|
||||||
|
<command>OCA\NgBackup\Command\TargetRemove</command>
|
||||||
|
<command>OCA\NgBackup\Command\RunBackup</command>
|
||||||
|
<command>OCA\NgBackup\Command\Status</command>
|
||||||
|
<command>OCA\NgBackup\Command\ListSnapshots</command>
|
||||||
|
<command>OCA\NgBackup\Command\Browse</command>
|
||||||
|
<command>OCA\NgBackup\Command\Restore</command>
|
||||||
|
</commands>
|
||||||
</info>
|
</info>
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "dillardblom/ng_backup",
|
||||||
|
"description": "NG Backup: encrypted, incremental Nextcloud backups",
|
||||||
|
"license": "AGPL-3.0-or-later",
|
||||||
|
"type": "project",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"OCA\\NgBackup\\": "lib/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"OCA\\NgBackup\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^10.5",
|
||||||
|
"nextcloud/ocp": "dev-stable32",
|
||||||
|
"vimeo/psalm": "^6.0"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"optimize-autoloader": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.2.27"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"test:unit": "phpunit -c tests/phpunit.xml --testsuite unit",
|
||||||
|
"psalm": "psalm --no-cache --threads=1"
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5045
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\BackgroundJob;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Service\BackupService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use OCP\AppFramework\Utility\ITimeFactory;
|
||||||
|
use OCP\BackgroundJob\IJob;
|
||||||
|
use OCP\BackgroundJob\TimedJob;
|
||||||
|
use OCP\IAppConfig;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts scheduled backups and moves running ones forward, a time-boxed step per cron run.
|
||||||
|
* With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits
|
||||||
|
* in a normal web request (hosted Nextcloud).
|
||||||
|
*/
|
||||||
|
class BackupJob extends TimedJob {
|
||||||
|
public function __construct(
|
||||||
|
ITimeFactory $time,
|
||||||
|
private BackupService $backups,
|
||||||
|
private TargetService $targets,
|
||||||
|
private RunMapper $runs,
|
||||||
|
private IAppConfig $appConfig,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
parent::__construct($time);
|
||||||
|
$this->setInterval(300);
|
||||||
|
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function run($argument): void {
|
||||||
|
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
||||||
|
$deadline = microtime(true) + $budget;
|
||||||
|
$this->startScheduled();
|
||||||
|
foreach ($this->runs->findRunning() as $run) {
|
||||||
|
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||||
|
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
||||||
|
private function startScheduled(): void {
|
||||||
|
$at = $this->appConfig->getValueString(Application::APP_ID, 'schedule_time', '');
|
||||||
|
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$todayAt = strtotime(date('Y-m-d') . ' ' . $at);
|
||||||
|
if (time() < $todayAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
foreach ($this->targets->list() as $t) {
|
||||||
|
$last = $this->runs->lastFinished($t->getId(), BackupService::KIND_FULL);
|
||||||
|
if ($this->runs->findRunning($t->getId()) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->backups->start($t, 'scheduled');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->warning('NG Backup: scheduled backup to ' . $t->getName() . ' not started: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<?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\RestoreService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Helper\Table;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class Browse extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private RestoreService $restore,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:browse')
|
||||||
|
->setDescription('Show folders and files in a snapshot')
|
||||||
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
|
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||||
|
->addArgument('path', InputArgument::OPTIONAL, 'Path in the snapshot, e.g. data/alice/files/Documents', '')
|
||||||
|
->setHelp("Top level: config/ and data/. User files are under data/<user>/files/.\n\nExample:\n occ backup:browse offsite 537bf7e9... data/alice/files");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
try {
|
||||||
|
$items = $this->restore->browse($this->targets->get($input->getArgument('target')), $input->getArgument('snapshot'), (string)$input->getArgument('path'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($items === []) {
|
||||||
|
$output->writeln('Nothing here.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$rows = array_map(fn ($i) => [$i['type'] === 'dir' ? $i['name'] . '/' : $i['name'], $this->size($i['size']),
|
||||||
|
$i['type'] === 'dir' ? $i['files'] . ' files' : date('Y-m-d H:i', (int)$i['mtime'])], $items);
|
||||||
|
(new Table($output))->setHeaders(['name', 'size', 'files / modified'])->setRows($rows)->render();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function size(int $b): string {
|
||||||
|
foreach (['B', 'KiB', 'MiB', 'GiB', 'TiB'] as $u) {
|
||||||
|
if ($b < 1024 || $u === 'TiB') {
|
||||||
|
return $u === 'B' ? "$b B" : sprintf('%.1f %s', $b, $u);
|
||||||
|
}
|
||||||
|
$b /= 1024;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?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\KeyService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Question\Question;
|
||||||
|
|
||||||
|
class KeyConfirm extends Command {
|
||||||
|
public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. '
|
||||||
|
. 'I understand that keeping these keys safe is my own responsibility and that backups cannot be restored without them.';
|
||||||
|
public const CONFIRMATION = 'Yes, I confirm';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private KeyService $keys,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:key:confirm')
|
||||||
|
->setDescription('Confirm that the recovery kit is stored safely (required before the first backup)')
|
||||||
|
->addOption('user', 'u', InputOption::VALUE_REQUIRED, 'Admin user id recorded with the confirmation', 'occ');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
if (!$this->keys->isInitialized()) {
|
||||||
|
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$output->writeln('Key fingerprint: <info>' . $this->keys->fingerprint() . '</info>');
|
||||||
|
$output->writeln('');
|
||||||
|
$output->writeln(wordwrap(self::STATEMENT, 78));
|
||||||
|
$output->writeln('');
|
||||||
|
$answer = (string)$this->getHelper('question')->ask($input, $output, new Question('Type "' . self::CONFIRMATION . '" to proceed: '));
|
||||||
|
if (trim($answer) !== self::CONFIRMATION) {
|
||||||
|
$output->writeln('<error>Not confirmed.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->keys->confirmRecoveryKit((string)$input->getOption('user'));
|
||||||
|
$output->writeln('Confirmed. Backups can now run.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?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\KeyService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
use Symfony\Component\Console\Question\Question;
|
||||||
|
|
||||||
|
class KeyInit extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private KeyService $keys,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:key:init')
|
||||||
|
->setDescription('Create the backup key, protected by a passphrase you keep safe')
|
||||||
|
->addOption('passphrase-file', null, InputOption::VALUE_REQUIRED, 'Read the passphrase from this file instead of asking')
|
||||||
|
->setHelp(<<<'HELP'
|
||||||
|
Creates the master key that encrypts all backups of this installation.
|
||||||
|
|
||||||
|
The server keeps the key (encrypted with this instance's secret) so scheduled backups can run.
|
||||||
|
A copy protected by your passphrase goes into every backup location and into the recovery kit
|
||||||
|
(occ backup:key:kit). To restore on a new server you need the recovery kit and the passphrase.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
occ backup:key:init
|
||||||
|
occ backup:key:kit --output=/root/ng_backup-recovery-kit.json
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
if ($this->keys->isInitialized()) {
|
||||||
|
$output->writeln('<error>A backup key already exists (fingerprint ' . $this->keys->fingerprint() . ').</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$file = $input->getOption('passphrase-file');
|
||||||
|
if ($file !== null) {
|
||||||
|
$pass = rtrim((string)@file_get_contents($file), "\r\n");
|
||||||
|
} else {
|
||||||
|
$helper = $this->getHelper('question');
|
||||||
|
$q = (new Question('Passphrase (min. 12 characters): '))->setHidden(true)->setHiddenFallback(false);
|
||||||
|
$pass = (string)$helper->ask($input, $output, $q);
|
||||||
|
$again = (string)$helper->ask($input, $output, (new Question('Repeat passphrase: '))->setHidden(true)->setHiddenFallback(false));
|
||||||
|
if ($pass !== $again) {
|
||||||
|
$output->writeln('<error>The passphrases do not match.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->keys->initialize($pass);
|
||||||
|
} catch (\InvalidArgumentException $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$output->writeln('Backup key created, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||||
|
$output->writeln('Next: save the recovery kit with <info>occ backup:key:kit --output=FILE</info> and confirm with <info>occ backup:key:confirm</info>.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?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\KeyService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class KeyKit extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private KeyService $keys,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:key:kit')
|
||||||
|
->setDescription('Write the recovery kit (passphrase-protected key) to a file or stdout')
|
||||||
|
->addOption('output', 'o', InputOption::VALUE_REQUIRED, 'File to write (default: stdout)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
if (!$this->keys->isInitialized()) {
|
||||||
|
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$json = json_encode($this->keys->recoveryKit(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n";
|
||||||
|
$file = $input->getOption('output');
|
||||||
|
if ($file === null) {
|
||||||
|
$output->write($json, false, OutputInterface::OUTPUT_RAW);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (file_put_contents($file, $json) === false) {
|
||||||
|
$output->writeln("<error>Cannot write $file</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
@chmod($file, 0600);
|
||||||
|
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?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\Db\SnapshotMapper;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Helper\Table;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class ListSnapshots extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private SnapshotMapper $snapshots,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:list')
|
||||||
|
->setDescription('List the snapshots (restore points) on a location')
|
||||||
|
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all)');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$name = $input->getArgument('target');
|
||||||
|
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||||
|
$rows = [];
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
foreach ($this->snapshots->findForTarget($t->getId()) as $s) {
|
||||||
|
$rows[] = [$t->getName(), $s->getSnapshotId(), date('Y-m-d H:i', $s->getCreatedAt()), $s->getKind(),
|
||||||
|
$s->getFiles(), sprintf('%.1f GiB', ($s->getBytes() ?? 0) / 1073741824), $s->getLabel() ?? ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($rows === []) {
|
||||||
|
$output->writeln('No snapshots yet (occ backup:run).');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
(new Table($output))->setHeaders(['location', 'snapshot', 'date', 'kind', 'files', 'size', 'label'])->setRows($rows)->render();
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?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\RestoreService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class Restore extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private RestoreService $restore,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:restore')
|
||||||
|
->setDescription('Restore files or folders from a snapshot')
|
||||||
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
|
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||||
|
->addArgument('path', InputArgument::REQUIRED, 'Path in the snapshot (see backup:browse)')
|
||||||
|
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'new-folder, merge or replace (user files)', RestoreService::MODE_NEW_FOLDER)
|
||||||
|
->addOption('to-directory', 'd', InputOption::VALUE_REQUIRED, 'Write to this local directory instead (any path, raw files)')
|
||||||
|
->setHelp(<<<'HELP'
|
||||||
|
User files (data/<user>/files/...) are restored through Nextcloud, so versions, trash and the
|
||||||
|
file cache stay correct. Modes:
|
||||||
|
new-folder (default) into "Restored <date>/<same path>" in the user's files; nothing existing is touched
|
||||||
|
merge into the original place; overwritten files keep their old content as a version
|
||||||
|
replace like merge, and files that are not in the snapshot are moved to the trash
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
occ backup:restore offsite 537bf7e9... data/alice/files/Documents
|
||||||
|
occ backup:restore offsite 537bf7e9... data/alice/files/Documents/plan.odt --mode=merge
|
||||||
|
occ backup:restore offsite 537bf7e9... config --to-directory=/root/restored-config
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
try {
|
||||||
|
$target = $this->targets->get($input->getArgument('target'));
|
||||||
|
$snapshot = $input->getArgument('snapshot');
|
||||||
|
$path = $input->getArgument('path');
|
||||||
|
if ($dir = $input->getOption('to-directory')) {
|
||||||
|
$n = $this->restore->restoreToDirectory($target, $snapshot, $path, $dir);
|
||||||
|
$output->writeln("Restored <info>$n</info> files to $dir.");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$r = $this->restore->restoreUserFiles($target, $snapshot, $path, (string)$input->getOption('mode'),
|
||||||
|
function (int $done, int $total, string $name) use ($output) {
|
||||||
|
if ($output->isVerbose() || $done === $total || $done % 100 === 0) {
|
||||||
|
$output->writeln(" $done/$total $name");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Restored <info>%d</info> files to %s%s.', $r['restored'], $r['target'], $r['trashed'] ? ", moved {$r['trashed']} extra files to the trash" : ''));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?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\Db\Run;
|
||||||
|
use OCA\NgBackup\Service\BackupService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class RunBackup extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private BackupService $backups,
|
||||||
|
private TargetService $targets,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:run')
|
||||||
|
->setDescription('Back up this Nextcloud (database, config, data) to a location now')
|
||||||
|
->addArgument('target', InputArgument::OPTIONAL, 'Location name or id (default: all locations)')
|
||||||
|
->addOption('label', 'l', InputOption::VALUE_REQUIRED, 'Label for the snapshot', '')
|
||||||
|
->addOption('background', 'b', InputOption::VALUE_NONE, 'Only start the run; background jobs finish it')
|
||||||
|
->setHelp(<<<'HELP'
|
||||||
|
Starts a backup (or continues one that is already running for that location) and runs it to
|
||||||
|
the end in this process. Unchanged files are not read again; only new data is uploaded.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
occ backup:run back up to every location
|
||||||
|
occ backup:run offsite -l "before upgrade"
|
||||||
|
occ backup:run offsite --background
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$name = $input->getArgument('target');
|
||||||
|
try {
|
||||||
|
$targets = $name === null ? $this->targets->list() : [$this->targets->get($name)];
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$output->writeln('<error>No such location.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if ($targets === []) {
|
||||||
|
$output->writeln('<error>No backup locations yet (occ backup:target:add).</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$failed = 0;
|
||||||
|
foreach ($targets as $t) {
|
||||||
|
try {
|
||||||
|
$run = $this->backups->start($t, (string)$input->getOption('label'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $t->getName() . ': ' . $e->getMessage() . '</error>');
|
||||||
|
$failed++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('<info>%s</info>: run %d, snapshot %s', $t->getName(), $run->getId(), $run->getSnapshotId()));
|
||||||
|
if ($input->getOption('background')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$start = microtime(true);
|
||||||
|
$run = $this->backups->runToCompletion($run, function (Run $r) use ($output, $start) {
|
||||||
|
$s = json_decode($r->getStats() ?? '{}', true);
|
||||||
|
$f = $s['files'] ?? [];
|
||||||
|
$output->writeln(sprintf(' %5.0fs %-6s files %d (read %d, unchanged %d), uploaded %.1f MiB',
|
||||||
|
microtime(true) - $start, $r->getPhase(), $f['files'] ?? 0, $f['read'] ?? 0, $f['reused'] ?? 0, ($f['uploaded'] ?? 0) / 1048576));
|
||||||
|
});
|
||||||
|
if ($run->getStatus() === Run::DONE) {
|
||||||
|
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
|
||||||
|
} else {
|
||||||
|
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
|
||||||
|
$failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $failed === 0 ? 0 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?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\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Service\KeyService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Helper\Table;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class Status extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private KeyService $keys,
|
||||||
|
private TargetService $targets,
|
||||||
|
private RunMapper $runs,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:status')->setDescription('Show the backup key, locations and recent runs');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
if (!$this->keys->isInitialized()) {
|
||||||
|
$output->writeln('Key: <error>not created</error> (occ backup:key:init)');
|
||||||
|
} else {
|
||||||
|
$c = $this->keys->recoveryKitConfirmation();
|
||||||
|
$output->writeln('Key: ' . $this->keys->fingerprint() . ', recovery kit '
|
||||||
|
. ($c ? 'confirmed by ' . $c['uid'] . ' on ' . date('Y-m-d H:i', $c['time']) : '<error>not confirmed</error> (occ backup:key:confirm)'));
|
||||||
|
}
|
||||||
|
$names = [];
|
||||||
|
foreach ($this->targets->list() as $t) {
|
||||||
|
$names[$t->getId()] = $t->getName();
|
||||||
|
}
|
||||||
|
$output->writeln('Locations: ' . ($names === [] ? 'none' : implode(', ', $names)));
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->runs->findRecent(10) as $r) {
|
||||||
|
$s = json_decode($r->getStats() ?? '{}', true)['files'] ?? [];
|
||||||
|
$rows[] = [$r->getId(), $names[$r->getTargetId()] ?? '#' . $r->getTargetId(), $r->getKind(), $r->getStatus() . ($r->getStatus() === 'running' ? ' (' . $r->getPhase() . ')' : ''),
|
||||||
|
date('Y-m-d H:i', $r->getStartedAt()), $r->getFinishedAt() ? gmdate('H:i:s', $r->getFinishedAt() - $r->getStartedAt()) : '',
|
||||||
|
$s['files'] ?? '', isset($s['uploaded']) ? sprintf('%.1f MiB', $s['uploaded'] / 1048576) : '', mb_strimwidth((string)$r->getError(), 0, 50, '…')];
|
||||||
|
}
|
||||||
|
if ($rows !== []) {
|
||||||
|
(new Table($output))->setHeaders(['run', 'location', 'kind', 'status', 'started', 'took', 'files', 'uploaded', 'error'])->setRows($rows)->render();
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?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\KeyService;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
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;
|
||||||
|
|
||||||
|
class TargetAdd extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private KeyService $keys,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:target:add')
|
||||||
|
->setDescription('Add a backup location (S3, SFTP, WebDAV, ...) via files_external, without mounting it')
|
||||||
|
->addArgument('name', InputArgument::REQUIRED, 'Name of the location')
|
||||||
|
->addArgument('backend', InputArgument::REQUIRED, 'files_external backend id (see backup:target:backends)')
|
||||||
|
->addOption('auth', 'a', InputOption::VALUE_REQUIRED, 'Auth mechanism id (see backup:target:backends)')
|
||||||
|
->addOption('option', 'o', InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, 'Backend/auth option key=value (repeatable)')
|
||||||
|
->addOption('path', 'p', InputOption::VALUE_REQUIRED, 'Folder on the location for this repository', 'ng_backup')
|
||||||
|
->setHelp(<<<'HELP'
|
||||||
|
The location is reached with files_external's own connection code, but it is never added as an
|
||||||
|
external storage: it does not appear in Files for anyone. Credentials are stored encrypted.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=backups -o hostname=s3.example.com -o region=eu-central-1 -o use_ssl=true -o use_path_style=true -o key=AKIA... -o secret=... --path=nextcloud
|
||||||
|
|
||||||
|
occ backup:target:add nas sftp -a password::password -o host=nas.example.lan -o root=/backups -o user=nextcloud -o password=... --path=ng_backup
|
||||||
|
HELP);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
if (!$this->keys->isInitialized()) {
|
||||||
|
$output->writeln('<error>No backup key yet: run occ backup:key:init first.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$options = [];
|
||||||
|
foreach ($input->getOption('option') as $pair) {
|
||||||
|
if (!str_contains($pair, '=')) {
|
||||||
|
$output->writeln("<error>Option '$pair' must be key=value</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
[$k, $v] = explode('=', $pair, 2);
|
||||||
|
$options[$k] = match (strtolower($v)) { 'true' => true, 'false' => false, default => $v };
|
||||||
|
}
|
||||||
|
if ($input->getOption('auth') === null) {
|
||||||
|
$output->writeln('<error>--auth is required (see occ backup:target:backends)</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$r = $this->targets->add($input->getArgument('name'), $input->getArgument('backend'), $input->getOption('auth'), $options, (string)$input->getOption('path'));
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$output->writeln(sprintf('Location <info>%s</info> added (id %d), repository %s %s.', $r['target']->getName(), $r['target']->getId(),
|
||||||
|
$r['target']->getRepositoryId(), $r['created'] ? 'created' : 'found and adopted'));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Command;
|
||||||
|
|
||||||
|
use OC\Core\Command\Base;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class TargetBackends extends Base {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
parent::configure();
|
||||||
|
$this->setName('backup:target:backends')
|
||||||
|
->setDescription('List the storage backends (from files_external) that can be used as backup location');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$backends = $this->targets->availableBackends();
|
||||||
|
if ($input->getOption('output') !== self::OUTPUT_FORMAT_PLAIN) {
|
||||||
|
$this->writeMixedInOutputFormat($input, $output, $backends);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
foreach ($backends as $b) {
|
||||||
|
$output->writeln(sprintf('<info>%s</info> (%s) auth schemes: %s', $b['id'], $b['name'], implode(', ', $b['auth'])));
|
||||||
|
foreach ($b['parameters'] as $key => $p) {
|
||||||
|
$output->writeln(sprintf(' -o %s=... %s', $key, is_array($p) ? ($p['value'] ?? '') : ''));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Command;
|
||||||
|
|
||||||
|
use OC\Core\Command\Base;
|
||||||
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class TargetList extends Base {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
parent::configure();
|
||||||
|
$this->setName('backup:target:list')
|
||||||
|
->setDescription('List backup locations')
|
||||||
|
->addOption('test', 't', InputOption::VALUE_NONE, 'Also connect to each location and check the repository');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$rows = [];
|
||||||
|
foreach ($this->targets->list() as $t) {
|
||||||
|
$row = ['id' => $t->getId(), 'name' => $t->getName(), 'backend' => $t->getBackend(), 'path' => $t->getBasePath(),
|
||||||
|
'repository' => $t->getRepositoryId(), 'added' => date('Y-m-d H:i', $t->getCreatedAt())];
|
||||||
|
if ($input->getOption('test')) {
|
||||||
|
try {
|
||||||
|
$repo = $this->targets->repository($t);
|
||||||
|
$row['status'] = 'ok, ' . count($repo->listSnapshots()) . ' snapshots';
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$row['status'] = 'error: ' . $e->getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rows[] = $row;
|
||||||
|
}
|
||||||
|
if ($input->getOption('output') === self::OUTPUT_FORMAT_PLAIN) {
|
||||||
|
if ($rows === []) {
|
||||||
|
$output->writeln('No backup locations yet (occ backup:target:add).');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$table = new \Symfony\Component\Console\Helper\Table($output);
|
||||||
|
$table->setHeaders(array_keys($rows[0]))->setRows(array_map('array_values', $rows))->render();
|
||||||
|
} else {
|
||||||
|
$this->writeMixedInOutputFormat($input, $output, $rows);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?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 Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
class TargetRemove extends Command {
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
) {
|
||||||
|
parent::__construct();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function configure(): void {
|
||||||
|
$this->setName('backup:target:remove')
|
||||||
|
->setDescription('Remove a backup location from NG Backup (the backups on it are kept)')
|
||||||
|
->addArgument('target', InputArgument::REQUIRED, 'Name or id');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
try {
|
||||||
|
$t = $this->targets->get($input->getArgument('target'));
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$output->writeln('<error>No such location.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$this->targets->remove($t);
|
||||||
|
$output->writeln('Removed <info>' . $t->getName() . '</info>. The backups on the location itself were not touched; add it again to use them.');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,11 @@ final class KeyRing {
|
|||||||
return new self(sodium_crypto_kdf_keygen());
|
return new self(sodium_crypto_kdf_keygen());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raw master key, only for storing it encrypted on the server (ICrypto). */
|
||||||
|
public function exportMasterKey(): string {
|
||||||
|
return $this->masterKey;
|
||||||
|
}
|
||||||
|
|
||||||
public function dataKey(): string {
|
public function dataKey(): string {
|
||||||
return $this->dataKey;
|
return $this->dataKey;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Repository\IIndexCache;
|
||||||
|
use OCP\DB\Exception;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
|
||||||
|
class DbIndexCache implements IIndexCache {
|
||||||
|
public function __construct(
|
||||||
|
private IDBConnection $db,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function all(string $repositoryId): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('pack_id', 'entries')->from('ngb_index')->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)));
|
||||||
|
$result = $qb->executeQuery();
|
||||||
|
$out = [];
|
||||||
|
while ($row = $result->fetch()) {
|
||||||
|
$out[$row['pack_id']] = json_decode($row['entries'], true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
$result->closeCursor();
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $repositoryId, string $packId, array $entries): void {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->insert('ngb_index')->values([
|
||||||
|
'repository_id' => $qb->createNamedParameter($repositoryId),
|
||||||
|
'pack_id' => $qb->createNamedParameter($packId),
|
||||||
|
'entries' => $qb->createNamedParameter(json_encode($entries, JSON_THROW_ON_ERROR)),
|
||||||
|
]);
|
||||||
|
try {
|
||||||
|
$qb->executeStatement();
|
||||||
|
} catch (Exception $e) {
|
||||||
|
if ($e->getReason() !== Exception::REASON_UNIQUE_CONSTRAINT_VIOLATION) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function remove(string $repositoryId, array $packIds): void {
|
||||||
|
foreach (array_chunk($packIds, 500) as $chunk) {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->delete('ngb_index')
|
||||||
|
->where($qb->expr()->eq('repository_id', $qb->createNamedParameter($repositoryId)))
|
||||||
|
->andWhere($qb->expr()->in('pack_id', $qb->createNamedParameter($chunk, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_STR_ARRAY)));
|
||||||
|
$qb->executeStatement();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\Entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method int getTargetId()
|
||||||
|
* @method void setTargetId(int $v)
|
||||||
|
* @method string getKind()
|
||||||
|
* @method void setKind(string $v)
|
||||||
|
* @method string getStatus()
|
||||||
|
* @method void setStatus(string $v)
|
||||||
|
* @method string getPhase()
|
||||||
|
* @method void setPhase(string $v)
|
||||||
|
* @method string getState()
|
||||||
|
* @method void setState(string $v)
|
||||||
|
* @method string|null getStats()
|
||||||
|
* @method void setStats(?string $v)
|
||||||
|
* @method string|null getError()
|
||||||
|
* @method void setError(?string $v)
|
||||||
|
* @method string|null getSnapshotId()
|
||||||
|
* @method void setSnapshotId(?string $v)
|
||||||
|
* @method int getStartedAt()
|
||||||
|
* @method void setStartedAt(int $v)
|
||||||
|
* @method int getUpdatedAt()
|
||||||
|
* @method void setUpdatedAt(int $v)
|
||||||
|
* @method int|null getFinishedAt()
|
||||||
|
* @method void setFinishedAt(?int $v)
|
||||||
|
*/
|
||||||
|
class Run extends Entity {
|
||||||
|
public const RUNNING = 'running';
|
||||||
|
public const DONE = 'done';
|
||||||
|
public const FAILED = 'failed';
|
||||||
|
public const CANCELLED = 'cancelled';
|
||||||
|
|
||||||
|
protected int $targetId = 0;
|
||||||
|
protected string $kind = '';
|
||||||
|
protected string $status = '';
|
||||||
|
protected string $phase = '';
|
||||||
|
protected string $state = '{}';
|
||||||
|
protected ?string $stats = null;
|
||||||
|
protected ?string $error = null;
|
||||||
|
protected ?string $snapshotId = null;
|
||||||
|
protected int $startedAt = 0;
|
||||||
|
protected int $updatedAt = 0;
|
||||||
|
protected ?int $finishedAt = null;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->addType('targetId', 'integer');
|
||||||
|
$this->addType('startedAt', 'integer');
|
||||||
|
$this->addType('updatedAt', 'integer');
|
||||||
|
$this->addType('finishedAt', 'integer');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\QBMapper;
|
||||||
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
|
||||||
|
/** @template-extends QBMapper<Run> */
|
||||||
|
class RunMapper extends QBMapper {
|
||||||
|
public function __construct(IDBConnection $db) {
|
||||||
|
parent::__construct($db, 'ngb_runs', Run::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): Run {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('id', $qb->createNamedParameter($id, IQueryBuilder::PARAM_INT)));
|
||||||
|
return $this->findEntity($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Run> */
|
||||||
|
public function findRunning(?int $targetId = null): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('status', $qb->createNamedParameter(Run::RUNNING)));
|
||||||
|
if ($targetId !== null) {
|
||||||
|
$qb->andWhere($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)));
|
||||||
|
}
|
||||||
|
return $this->findEntities($qb->orderBy('id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Run> */
|
||||||
|
public function findRecent(int $limit = 20): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())->orderBy('id', 'DESC')->setMaxResults($limit);
|
||||||
|
return $this->findEntities($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function lastFinished(int $targetId, string $kind): ?Run {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||||
|
->andWhere($qb->expr()->eq('kind', $qb->createNamedParameter($kind)))
|
||||||
|
->andWhere($qb->expr()->eq('status', $qb->createNamedParameter(Run::DONE)))
|
||||||
|
->orderBy('id', 'DESC')->setMaxResults(1);
|
||||||
|
$r = $this->findEntities($qb);
|
||||||
|
return $r[0] ?? null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\Entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method int getTargetId()
|
||||||
|
* @method void setTargetId(int $v)
|
||||||
|
* @method string getSnapshotId()
|
||||||
|
* @method void setSnapshotId(string $v)
|
||||||
|
* @method string getKind()
|
||||||
|
* @method void setKind(string $v)
|
||||||
|
* @method string|null getLabel()
|
||||||
|
* @method void setLabel(?string $v)
|
||||||
|
* @method int getCreatedAt()
|
||||||
|
* @method void setCreatedAt(int $v)
|
||||||
|
* @method int|null getFiles()
|
||||||
|
* @method void setFiles(?int $v)
|
||||||
|
* @method int|null getBytes()
|
||||||
|
* @method void setBytes(?int $v)
|
||||||
|
*/
|
||||||
|
class Snapshot extends Entity {
|
||||||
|
protected int $targetId = 0;
|
||||||
|
protected string $snapshotId = '';
|
||||||
|
protected string $kind = '';
|
||||||
|
protected ?string $label = null;
|
||||||
|
protected int $createdAt = 0;
|
||||||
|
protected ?int $files = null;
|
||||||
|
protected ?int $bytes = null;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->addType('targetId', 'integer');
|
||||||
|
$this->addType('createdAt', 'integer');
|
||||||
|
$this->addType('files', 'integer');
|
||||||
|
$this->addType('bytes', 'integer');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\QBMapper;
|
||||||
|
use OCP\DB\QueryBuilder\IQueryBuilder;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
|
||||||
|
/** @template-extends QBMapper<Snapshot> */
|
||||||
|
class SnapshotMapper extends QBMapper {
|
||||||
|
public function __construct(IDBConnection $db) {
|
||||||
|
parent::__construct($db, 'ngb_snapshots', Snapshot::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Snapshot> newest first */
|
||||||
|
public function findForTarget(int $targetId): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||||
|
->orderBy('created_at', 'DESC');
|
||||||
|
return $this->findEntities($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findOne(int $targetId, string $snapshotId): Snapshot {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('target_id', $qb->createNamedParameter($targetId, IQueryBuilder::PARAM_INT)))
|
||||||
|
->andWhere($qb->expr()->eq('snapshot_id', $qb->createNamedParameter($snapshotId)));
|
||||||
|
return $this->findEntity($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function latest(int $targetId, string $kind): ?Snapshot {
|
||||||
|
foreach ($this->findForTarget($targetId) as $s) {
|
||||||
|
if ($s->getKind() === $kind) {
|
||||||
|
return $s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\Entity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @method string getName()
|
||||||
|
* @method void setName(string $v)
|
||||||
|
* @method string getBackend()
|
||||||
|
* @method void setBackend(string $v)
|
||||||
|
* @method string getAuth()
|
||||||
|
* @method void setAuth(string $v)
|
||||||
|
* @method string getOptions()
|
||||||
|
* @method void setOptions(string $v)
|
||||||
|
* @method string getBasePath()
|
||||||
|
* @method void setBasePath(string $v)
|
||||||
|
* @method string|null getRepositoryId()
|
||||||
|
* @method void setRepositoryId(?string $v)
|
||||||
|
* @method int getCreatedAt()
|
||||||
|
* @method void setCreatedAt(int $v)
|
||||||
|
*/
|
||||||
|
class Target extends Entity {
|
||||||
|
protected string $name = '';
|
||||||
|
protected string $backend = '';
|
||||||
|
protected string $auth = '';
|
||||||
|
protected string $options = '';
|
||||||
|
protected string $basePath = '';
|
||||||
|
protected ?string $repositoryId = null;
|
||||||
|
protected int $createdAt = 0;
|
||||||
|
|
||||||
|
public function __construct() {
|
||||||
|
$this->addType('createdAt', 'integer');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Db;
|
||||||
|
|
||||||
|
use OCP\AppFramework\Db\QBMapper;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
|
||||||
|
/** @template-extends QBMapper<Target> */
|
||||||
|
class TargetMapper extends QBMapper {
|
||||||
|
public function __construct(IDBConnection $db) {
|
||||||
|
parent::__construct($db, 'ngb_targets', Target::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function find(int $id): Target {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())
|
||||||
|
->where($qb->expr()->eq('id', $qb->createNamedParameter($id, \OCP\DB\QueryBuilder\IQueryBuilder::PARAM_INT)));
|
||||||
|
return $this->findEntity($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByName(string $name): Target {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())->where($qb->expr()->eq('name', $qb->createNamedParameter($name)));
|
||||||
|
return $this->findEntity($qb);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Target> */
|
||||||
|
public function findAll(): array {
|
||||||
|
$qb = $this->db->getQueryBuilder();
|
||||||
|
$qb->select('*')->from($this->getTableName())->orderBy('name');
|
||||||
|
return $this->findEntities($qb);
|
||||||
|
}
|
||||||
|
}
|
||||||
+82
-98
@@ -10,158 +10,142 @@ namespace OCA\NgBackup\Job;
|
|||||||
use OCA\NgBackup\Repository\Repository;
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A backup run as a resumable state machine, for environments with short PHP time limits
|
* A file backup as a resumable state machine, for environments with short PHP time limits.
|
||||||
* (webcron/AJAX cron on hosted Nextcloud). Each step works until its deadline, then uploads
|
* Each step works until its deadline, uploads what it has (pack + tree segment) and returns
|
||||||
* what it has (pack + tree segment) and returns the new state. Even a single large file is
|
* the new state; a large file is resumed at the next blob.
|
||||||
* resumed at the blob where the previous step stopped.
|
|
||||||
*
|
*
|
||||||
* The state is a plain array (in the app: a database row). It is only saved after a step
|
* The state holds no file list: the position is a cursor in TreeWalker order, and the previous
|
||||||
* completes; if a process is killed mid-step, the next step redoes that work. Blobs uploaded by
|
* snapshot is merge-joined through ParentCursor, so memory and state size stay small however
|
||||||
* the killed step are not referenced and are removed later by pruning; nothing is corrupted,
|
* many files there are. The snapshot is written last; a killed step is simply redone.
|
||||||
* because the snapshot is written last.
|
|
||||||
*/
|
*/
|
||||||
final class BackupRun {
|
final class BackupRun {
|
||||||
public static function start(string $source, ?string $parent = null, string $label = ''): array {
|
/**
|
||||||
|
* @param string|array<string, string> $roots one directory, or name => directory (walked in order)
|
||||||
|
* @param list<string> $exclude logical path prefixes to skip
|
||||||
|
* @param array<string, mixed> $meta extra fields for the snapshot meta record
|
||||||
|
*/
|
||||||
|
public static function start(string|array $roots, ?string $parent = null, string $label = '', array $exclude = [], array $meta = []): array {
|
||||||
|
$roots = is_string($roots) ? ['' => rtrim($roots, '/')] : $roots;
|
||||||
|
uksort($roots, fn ($a, $b) => TreeWalker::compare((string)$a, (string)$b)); // walk order = compare order
|
||||||
return [
|
return [
|
||||||
'snapshot' => bin2hex(random_bytes(16)),
|
'snapshot' => bin2hex(random_bytes(16)),
|
||||||
'source' => rtrim($source, '/'),
|
'roots' => $roots,
|
||||||
|
'exclude' => $exclude,
|
||||||
'parent' => $parent,
|
'parent' => $parent,
|
||||||
'label' => $label,
|
'label' => $label,
|
||||||
|
'meta' => $meta,
|
||||||
'started' => gmdate('c'),
|
'started' => gmdate('c'),
|
||||||
'phase' => 'scan',
|
'phase' => 'files',
|
||||||
'files' => [],
|
'after' => null,
|
||||||
'pos' => 0,
|
'parentPos' => 0,
|
||||||
'cur' => null,
|
'cur' => null,
|
||||||
'segments' => [],
|
'segments' => [],
|
||||||
'steps' => 0,
|
'steps' => 0,
|
||||||
'stats' => ['files' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
|
'stats' => ['files' => 0, 'bytes' => 0, 'reused' => 0, 'read' => 0, 'newBlobs' => 0, 'dupBlobs' => 0, 'uploaded' => 0],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Run one step until $deadline (microtime(true)); returns the new state. */
|
/** Run one step until $deadline (microtime(true)); returns the new state. */
|
||||||
public static function step(Repository $repo, array $state, float $deadline): array {
|
public static function step(Repository $repo, array $state, float $deadline): array {
|
||||||
$state['steps']++;
|
$state['steps']++;
|
||||||
if ($state['phase'] === 'scan') {
|
|
||||||
$state['files'] = self::scan($state['source']);
|
|
||||||
$state['phase'] = 'files';
|
|
||||||
// Scanning can take a while on big trees; continue in the same step if time is left.
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($state['phase'] === 'files') {
|
if ($state['phase'] === 'files') {
|
||||||
$previous = $state['parent'] !== null ? $repo->snapshotEntries($state['parent']) : [];
|
$walker = new TreeWalker($state['roots'], $state['exclude']);
|
||||||
|
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
||||||
$stats = $state['stats'];
|
$stats = $state['stats'];
|
||||||
$lines = [];
|
$lines = [];
|
||||||
$total = count($state['files']);
|
$finishedWalk = true;
|
||||||
while ($state['pos'] < $total) {
|
|
||||||
$relative = $state['files'][$state['pos']];
|
foreach ($walker->walk($state['after']) as $logical => $path) {
|
||||||
$path = $state['source'] . '/' . $relative;
|
$version = Repository::fileVersion($path);
|
||||||
clearstatcache(true, $path);
|
if ($version === 'missing') {
|
||||||
$size = @filesize($path);
|
continue; // deleted while walking
|
||||||
$mtime = @filemtime($path);
|
|
||||||
if ($size === false) {
|
|
||||||
// Deleted since the scan: skip.
|
|
||||||
$state['pos']++;
|
|
||||||
$state['cur'] = null;
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
$prev = $previous[$relative] ?? null;
|
[$size, $mtime] = array_map('intval', explode(':', $version, 3));
|
||||||
|
$prev = $parent->find($logical);
|
||||||
|
|
||||||
if ($state['cur'] === null && $prev !== null && $prev['s'] === $size && $prev['m'] === $mtime) {
|
if ($state['cur'] === null && $prev !== null && $prev['s'] === $size && $prev['m'] === $mtime) {
|
||||||
$lines[] = self::line($relative, $size, $mtime, $prev['b']);
|
$blobs = $prev['b'];
|
||||||
$stats['files']++;
|
|
||||||
$stats['reused']++;
|
$stats['reused']++;
|
||||||
$state['pos']++;
|
} else {
|
||||||
continue;
|
$cur = $state['cur'];
|
||||||
|
if ($cur === null || $cur['path'] !== $logical || $cur['version'] !== $version) {
|
||||||
|
if ($cur !== null && $cur['path'] === $logical) {
|
||||||
|
$stats['retried'] = ($stats['retried'] ?? 0) + 1; // changed since the previous step
|
||||||
|
}
|
||||||
|
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
||||||
|
}
|
||||||
|
$fh = fopen($path, 'rb');
|
||||||
|
if ($fh === false) {
|
||||||
|
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
|
||||||
|
$state['cur'] = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
fseek($fh, $cur['offset']);
|
||||||
|
$complete = true;
|
||||||
|
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
|
||||||
|
$cur['blobs'][] = $repo->storeData($data, $stats);
|
||||||
|
$cur['offset'] += strlen($data);
|
||||||
|
if (microtime(true) >= $deadline && !feof($fh)) {
|
||||||
|
$complete = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fclose($fh);
|
||||||
|
if (Repository::fileVersion($path) !== $cur['version']) {
|
||||||
|
// Changed while reading: read it again from the start (now or next step).
|
||||||
|
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
||||||
|
$state['cur'] = null;
|
||||||
|
$finishedWalk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (!$complete) {
|
||||||
|
$state['cur'] = $cur;
|
||||||
|
$finishedWalk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$blobs = $cur['blobs'];
|
||||||
|
$state['cur'] = null;
|
||||||
|
$stats['read']++;
|
||||||
}
|
}
|
||||||
|
|
||||||
$version = Repository::fileVersion($path);
|
$lines[] = json_encode(['p' => $logical, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
||||||
$cur = $state['cur'];
|
|
||||||
if ($cur === null || $cur['version'] !== $version) {
|
|
||||||
// New file, or it changed since the previous step: (re)start reading it.
|
|
||||||
if ($cur !== null) {
|
|
||||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
$cur = ['path' => $relative, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
|
||||||
}
|
|
||||||
$fh = fopen($path, 'rb');
|
|
||||||
fseek($fh, $cur['offset']);
|
|
||||||
$finished = true;
|
|
||||||
while (($data = self::readFull($fh, Repository::BLOB_SIZE)) !== '') {
|
|
||||||
$cur['blobs'][] = $repo->storeData($data, $stats);
|
|
||||||
$cur['offset'] += strlen($data);
|
|
||||||
if (microtime(true) >= $deadline && !feof($fh)) {
|
|
||||||
$finished = false;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
fclose($fh);
|
|
||||||
if (Repository::fileVersion($path) !== $cur['version']) {
|
|
||||||
// Changed while reading: start this file again (in this or the next step).
|
|
||||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
|
||||||
$state['cur'] = null;
|
|
||||||
if (microtime(true) >= $deadline) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (!$finished) {
|
|
||||||
$state['cur'] = $cur;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
$lines[] = self::line($relative, $size, $mtime, $cur['blobs']);
|
|
||||||
$stats['files']++;
|
$stats['files']++;
|
||||||
$stats['read']++;
|
$stats['bytes'] += $size;
|
||||||
$state['cur'] = null;
|
$state['after'] = $logical;
|
||||||
$state['pos']++;
|
|
||||||
if (microtime(true) >= $deadline) {
|
if (microtime(true) >= $deadline) {
|
||||||
|
$finishedWalk = false;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist this step's tree lines as a blob stream segment and upload the open pack.
|
|
||||||
if ($lines !== []) {
|
if ($lines !== []) {
|
||||||
$writer = $repo->blobWriter($stats);
|
$writer = $repo->blobWriter($stats);
|
||||||
$writer->write(implode("\n", $lines) . "\n");
|
$writer->write(implode("\n", $lines) . "\n");
|
||||||
$state['segments'][] = $writer->finish();
|
$state['segments'][] = $writer->finish();
|
||||||
}
|
}
|
||||||
$repo->flushPacks();
|
$repo->flushPacks();
|
||||||
|
$state['parentPos'] = $parent->position();
|
||||||
$state['stats'] = $stats;
|
$state['stats'] = $stats;
|
||||||
if ($state['pos'] >= $total) {
|
if ($finishedWalk) {
|
||||||
$state['phase'] = 'finish';
|
$state['phase'] = 'finish';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($state['phase'] === 'finish' && microtime(true) < $deadline + 5) {
|
if ($state['phase'] === 'finish') {
|
||||||
$segments = $state['segments'];
|
$segments = $state['segments'];
|
||||||
$repo->writeSnapshot($state['snapshot'],
|
$repo->writeSnapshot($state['snapshot'],
|
||||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'], 'source' => $state['source']],
|
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
||||||
|
'roots' => array_keys($state['roots']), 'stats' => $state['stats']] + $state['meta'],
|
||||||
(function () use ($repo, $segments) {
|
(function () use ($repo, $segments) {
|
||||||
foreach ($segments as $blobs) {
|
foreach ($segments as $blobs) {
|
||||||
yield from $repo->readLines($blobs);
|
yield from $repo->readLines($blobs);
|
||||||
}
|
}
|
||||||
})());
|
})());
|
||||||
$state['phase'] = 'done';
|
$state['phase'] = 'done';
|
||||||
$state['files'] = [];
|
|
||||||
}
|
}
|
||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private static function scan(string $root): array {
|
|
||||||
$files = [];
|
|
||||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
|
|
||||||
foreach ($it as $file) {
|
|
||||||
if ($file->isFile()) {
|
|
||||||
$files[] = substr($file->getPathname(), strlen($root) + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sort($files, SORT_STRING);
|
|
||||||
return $files;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function line(string $path, int $size, int $mtime, array $blobs): string {
|
|
||||||
return json_encode(['p' => $path, 's' => $size, 'm' => $mtime, 'b' => $blobs], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static function readFull($fh, int $length): string {
|
private static function readFull($fh, int $length): string {
|
||||||
$buffer = '';
|
$buffer = '';
|
||||||
while (strlen($buffer) < $length && !feof($fh)) {
|
while (strlen($buffer) < $length && !feof($fh)) {
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Job;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the previous snapshot's entries in walk order and answers "what did the parent have
|
||||||
|
* at this path?" for increasing paths (merge-join). Only the current entry is in memory; the
|
||||||
|
* number of entries consumed is part of the resumable state.
|
||||||
|
*/
|
||||||
|
final class ParentCursor {
|
||||||
|
private ?\Generator $entries = null;
|
||||||
|
private ?array $current = null;
|
||||||
|
private int $consumed = 0;
|
||||||
|
|
||||||
|
public function __construct(Repository $repo, ?string $snapshotId, int $skip = 0) {
|
||||||
|
if ($snapshotId === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->entries = $repo->entries($snapshotId);
|
||||||
|
$this->current = $this->entries->valid() ? $this->entries->current() : null;
|
||||||
|
while ($this->consumed < $skip && $this->current !== null) {
|
||||||
|
$this->advance();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{p:string, s:int, m:int, b:list<string>}|null */
|
||||||
|
public function find(string $path): ?array {
|
||||||
|
while ($this->current !== null) {
|
||||||
|
$c = TreeWalker::compare($this->current['p'], $path);
|
||||||
|
if ($c === 0) {
|
||||||
|
return $this->current;
|
||||||
|
}
|
||||||
|
if ($c > 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$this->advance();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function position(): int {
|
||||||
|
return $this->consumed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function advance(): void {
|
||||||
|
$this->entries->next();
|
||||||
|
$this->consumed++;
|
||||||
|
$this->current = $this->entries->valid() ? $this->entries->current() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Job;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks one or more source roots in a stable depth-first order (entries of a directory sorted
|
||||||
|
* by name, children right after their directory), yielding "<root>/<relative path>" for files.
|
||||||
|
*
|
||||||
|
* The same order is used for comparing paths (compare()), so a walk can resume after a cursor
|
||||||
|
* and be merge-joined with the previous snapshot without holding any list in memory.
|
||||||
|
*/
|
||||||
|
final class TreeWalker {
|
||||||
|
/**
|
||||||
|
* @param array<string, string> $roots name => absolute directory, walked in this order
|
||||||
|
* @param list<string> $exclude path prefixes ("<root>/<relative>") that are skipped entirely;
|
||||||
|
* a pattern with "*" matches one path segment (fnmatch, FNM_PATHNAME)
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private array $roots,
|
||||||
|
private array $exclude = [],
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return \Generator<string, string> logical path => absolute path, strictly after $after */
|
||||||
|
public function walk(?string $after = null): \Generator {
|
||||||
|
foreach ($this->roots as $name => $dir) {
|
||||||
|
$name = (string)$name;
|
||||||
|
if ($name !== '' && $after !== null && self::compare($name, self::firstSegment($after)) < 0) {
|
||||||
|
continue; // whole root lies before the cursor
|
||||||
|
}
|
||||||
|
yield from $this->walkDir(rtrim($dir, '/'), $name, $after);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Order used everywhere: segment by segment, byte-wise. */
|
||||||
|
public static function compare(string $a, string $b): int {
|
||||||
|
$sa = explode('/', $a);
|
||||||
|
$sb = explode('/', $b);
|
||||||
|
$n = min(count($sa), count($sb));
|
||||||
|
for ($i = 0; $i < $n; $i++) {
|
||||||
|
$c = strcmp($sa[$i], $sb[$i]);
|
||||||
|
if ($c !== 0) {
|
||||||
|
return $c < 0 ? -1 : 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count($sa) <=> count($sb);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function walkDir(string $abs, string $logical, ?string $after): \Generator {
|
||||||
|
if ($logical !== '' && $this->isExcluded($logical, true)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$names = @scandir($abs);
|
||||||
|
if ($names === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$names = array_values(array_diff($names, ['.', '..']));
|
||||||
|
sort($names, SORT_STRING);
|
||||||
|
foreach ($names as $name) {
|
||||||
|
$childLogical = $logical === '' ? $name : $logical . '/' . $name;
|
||||||
|
$childAbs = $abs . '/' . $name;
|
||||||
|
if (is_link($childAbs)) {
|
||||||
|
continue; // never follow symlinks out of the data directory
|
||||||
|
}
|
||||||
|
if (is_dir($childAbs)) {
|
||||||
|
// Skip a directory entirely if everything in it sorts before the cursor.
|
||||||
|
if ($after !== null && self::compare($childLogical, $after) < 0 && !self::isPrefix($childLogical, $after)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
yield from $this->walkDir($childAbs, $childLogical, $after);
|
||||||
|
} elseif (is_file($childAbs)) {
|
||||||
|
if ($after !== null && self::compare($childLogical, $after) <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!$this->isExcluded($childLogical, false)) {
|
||||||
|
yield $childLogical => $childAbs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isExcluded(string $logical, bool $isDir): bool {
|
||||||
|
foreach ($this->exclude as $pattern) {
|
||||||
|
if (str_contains($pattern, '*')) {
|
||||||
|
if (fnmatch($pattern, $logical, FNM_PATHNAME)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} elseif ($logical === $pattern || ($isDir && str_starts_with($logical, $pattern . '/'))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function isPrefix(string $dir, string $path): bool {
|
||||||
|
return str_starts_with($path, $dir . '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function firstSegment(string $path): string {
|
||||||
|
return explode('/', $path, 2)[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Migration;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use OCP\DB\ISchemaWrapper;
|
||||||
|
use OCP\DB\Types;
|
||||||
|
use OCP\Migration\IOutput;
|
||||||
|
use OCP\Migration\SimpleMigrationStep;
|
||||||
|
|
||||||
|
class Version000001Date20261002000000 extends SimpleMigrationStep {
|
||||||
|
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||||
|
/** @var ISchemaWrapper $schema */
|
||||||
|
$schema = $schemaClosure();
|
||||||
|
|
||||||
|
if (!$schema->hasTable('ngb_targets')) {
|
||||||
|
$t = $schema->createTable('ngb_targets');
|
||||||
|
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('name', Types::STRING, ['notnull' => true, 'length' => 64]);
|
||||||
|
$t->addColumn('backend', Types::STRING, ['notnull' => true, 'length' => 64]);
|
||||||
|
$t->addColumn('auth', Types::STRING, ['notnull' => true, 'length' => 64]);
|
||||||
|
// Backend/auth options as JSON, encrypted with ICrypto (they contain credentials).
|
||||||
|
$t->addColumn('options', Types::TEXT, ['notnull' => true]);
|
||||||
|
$t->addColumn('base_path', Types::STRING, ['notnull' => false, 'length' => 255, 'default' => '']);
|
||||||
|
$t->addColumn('repository_id', Types::STRING, ['notnull' => false, 'length' => 32]);
|
||||||
|
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->setPrimaryKey(['id']);
|
||||||
|
$t->addUniqueIndex(['name'], 'ngb_targets_name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$schema->hasTable('ngb_runs')) {
|
||||||
|
$t = $schema->createTable('ngb_runs');
|
||||||
|
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
|
||||||
|
$t->addColumn('status', Types::STRING, ['notnull' => true, 'length' => 16]);
|
||||||
|
$t->addColumn('phase', Types::STRING, ['notnull' => true, 'length' => 32]);
|
||||||
|
// Resumable state (JSON). Large lists live in the repository, not here.
|
||||||
|
$t->addColumn('state', Types::TEXT, ['notnull' => true]);
|
||||||
|
$t->addColumn('stats', Types::TEXT, ['notnull' => false]);
|
||||||
|
$t->addColumn('error', Types::TEXT, ['notnull' => false]);
|
||||||
|
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => false, 'length' => 32]);
|
||||||
|
$t->addColumn('started_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('updated_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('finished_at', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
||||||
|
$t->setPrimaryKey(['id']);
|
||||||
|
$t->addIndex(['target_id', 'status'], 'ngb_runs_target_status');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$schema->hasTable('ngb_snapshots')) {
|
||||||
|
// Cache of the snapshots in each repository, for listing without reading the target.
|
||||||
|
$t = $schema->createTable('ngb_snapshots');
|
||||||
|
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('target_id', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('snapshot_id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
||||||
|
$t->addColumn('kind', Types::STRING, ['notnull' => true, 'length' => 16]);
|
||||||
|
$t->addColumn('label', Types::STRING, ['notnull' => false, 'length' => 255]);
|
||||||
|
$t->addColumn('created_at', Types::BIGINT, ['notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('files', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
||||||
|
$t->addColumn('bytes', Types::BIGINT, ['notnull' => false, 'unsigned' => true]);
|
||||||
|
$t->setPrimaryKey(['id']);
|
||||||
|
$t->addUniqueIndex(['target_id', 'snapshot_id'], 'ngb_snap_target_snap');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Migration;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use OCP\DB\ISchemaWrapper;
|
||||||
|
use OCP\DB\Types;
|
||||||
|
use OCP\Migration\IOutput;
|
||||||
|
use OCP\Migration\SimpleMigrationStep;
|
||||||
|
|
||||||
|
/** Local cache of the per-pack index files, so opening a repository does not download them all. */
|
||||||
|
class Version000002Date20261002010000 extends SimpleMigrationStep {
|
||||||
|
public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper {
|
||||||
|
/** @var ISchemaWrapper $schema */
|
||||||
|
$schema = $schemaClosure();
|
||||||
|
if (!$schema->hasTable('ngb_index')) {
|
||||||
|
$t = $schema->createTable('ngb_index');
|
||||||
|
$t->addColumn('id', Types::BIGINT, ['autoincrement' => true, 'notnull' => true, 'unsigned' => true]);
|
||||||
|
$t->addColumn('repository_id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
||||||
|
$t->addColumn('pack_id', Types::STRING, ['notnull' => true, 'length' => 32]);
|
||||||
|
// Plain JSON list of blob entries. Blob ids are keyed hashes and reveal no content.
|
||||||
|
$t->addColumn('entries', Types::TEXT, ['notnull' => true]);
|
||||||
|
$t->setPrimaryKey(['id']);
|
||||||
|
$t->addUniqueIndex(['repository_id', 'pack_id'], 'ngb_index_repo_pack');
|
||||||
|
}
|
||||||
|
return $schema;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,22 +24,51 @@ final class BlobIndex {
|
|||||||
/** @var array<string, true> blobs added in this run but whose pack is not uploaded yet */
|
/** @var array<string, true> blobs added in this run but whose pack is not uploaded yet */
|
||||||
private array $pending = [];
|
private array $pending = [];
|
||||||
|
|
||||||
|
private ?IIndexCache $cache = null;
|
||||||
|
private string $repositoryId = '';
|
||||||
|
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
||||||
|
public int $downloaded = 0;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private IBackend $backend,
|
private IBackend $backend,
|
||||||
private StreamCipher $cipher,
|
private StreamCipher $cipher,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function setCache(IIndexCache $cache, string $repositoryId): void {
|
||||||
|
$this->cache = $cache;
|
||||||
|
$this->repositoryId = $repositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the index: one listing of index/, entries from the local cache where available,
|
||||||
|
* and only the index files that are not cached yet are downloaded. Cached packs that no
|
||||||
|
* longer exist on the backend are dropped from the cache.
|
||||||
|
*/
|
||||||
public function load(): void {
|
public function load(): void {
|
||||||
|
$cached = $this->cache?->all($this->repositoryId) ?? [];
|
||||||
|
$present = [];
|
||||||
|
$this->downloaded = 0;
|
||||||
foreach ($this->backend->list('index') as $path) {
|
foreach ($this->backend->list('index') as $path) {
|
||||||
$packId = basename($path);
|
$packId = basename($path);
|
||||||
$fh = $this->backend->get($path);
|
$present[$packId] = true;
|
||||||
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
if (isset($cached[$packId])) {
|
||||||
fclose($fh);
|
$entries = $cached[$packId];
|
||||||
|
} else {
|
||||||
|
$fh = $this->backend->get($path);
|
||||||
|
$entries = json_decode($this->cipher->decryptString((string)stream_get_contents($fh), 'index:' . $packId), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
fclose($fh);
|
||||||
|
$this->downloaded++;
|
||||||
|
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||||
|
}
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$stale = array_keys(array_diff_key($cached, $present));
|
||||||
|
if ($stale !== []) {
|
||||||
|
$this->cache?->remove($this->repositoryId, $stale);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function has(string $id): bool {
|
public function has(string $id): bool {
|
||||||
@@ -68,6 +97,7 @@ final class BlobIndex {
|
|||||||
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
||||||
rewind($stream);
|
rewind($stream);
|
||||||
$this->backend->put('index/' . $packId, $stream);
|
$this->backend->put('index/' . $packId, $stream);
|
||||||
|
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||||
foreach ($entries as $e) {
|
foreach ($entries as $e) {
|
||||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||||
unset($this->pending[$e['id']]);
|
unset($this->pending[$e['id']]);
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Repository;
|
||||||
|
|
||||||
|
/** Optional local cache of pack index entries (the repository's index/ files stay authoritative). */
|
||||||
|
interface IIndexCache {
|
||||||
|
/** @return array<string, list<array{id:string, offset:int, length:int, raw:int, flags:int}>> packId => entries */
|
||||||
|
public function all(string $repositoryId): array;
|
||||||
|
|
||||||
|
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||||
|
public function put(string $repositoryId, string $packId, array $entries): void;
|
||||||
|
|
||||||
|
/** @param list<string> $packIds */
|
||||||
|
public function remove(string $repositoryId, array $packIds): void;
|
||||||
|
}
|
||||||
@@ -43,17 +43,51 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
return self::initWithKey($backend, $keys, $keys->wrap($passphrase));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a repository with an existing master key. $wrappedKey is the passphrase-wrapped
|
||||||
|
* copy (KeyRing::wrap) stored in the repository config, so the repository can be opened
|
||||||
|
* with the passphrase from the recovery kit even when the server is gone.
|
||||||
|
*/
|
||||||
|
public static function initWithKey(IBackend $backend, KeyRing $keys, array $wrappedKey): self {
|
||||||
if ($backend->exists('config')) {
|
if ($backend->exists('config')) {
|
||||||
throw new RepositoryException('A repository already exists at this location');
|
throw new RepositoryException('A repository already exists at this location');
|
||||||
}
|
}
|
||||||
$keys = KeyRing::generate();
|
|
||||||
$id = bin2hex(random_bytes(16));
|
$id = bin2hex(random_bytes(16));
|
||||||
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $keys->wrap($passphrase)];
|
$config = ['format' => self::FORMAT, 'id' => $id, 'created' => gmdate('c'), 'key' => $wrappedKey,
|
||||||
|
'check' => base64_encode((new StreamCipher($keys))->encryptString($id, 'config-check'))];
|
||||||
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
self::putString($backend, 'config', json_encode($config, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
|
||||||
return new self($backend, $keys, $id);
|
return new self($backend, $keys, $id);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
/** Open with the master key the server holds (scheduled backups, no passphrase needed). */
|
||||||
|
public static function openWithKey(IBackend $backend, KeyRing $keys, ?IIndexCache $cache = null): self {
|
||||||
|
$config = self::readConfig($backend);
|
||||||
|
try {
|
||||||
|
$ok = isset($config['check']) && (new StreamCipher($keys))->decryptString((string)base64_decode($config['check'], true), 'config-check') === $config['id'];
|
||||||
|
} catch (\OCA\NgBackup\Crypto\CryptoException) {
|
||||||
|
$ok = false;
|
||||||
|
}
|
||||||
|
if (!$ok) {
|
||||||
|
throw new RepositoryException('This repository was created with a different key');
|
||||||
|
}
|
||||||
|
$repo = new self($backend, $keys, $config['id']);
|
||||||
|
if ($cache !== null) {
|
||||||
|
$repo->index->setCache($cache, $config['id']);
|
||||||
|
}
|
||||||
|
$repo->index->load();
|
||||||
|
return $repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Index files downloaded when this repository was opened (0 when the local cache was complete). */
|
||||||
|
public function indexDownloads(): int {
|
||||||
|
return $this->index->downloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function readConfig(IBackend $backend): array {
|
||||||
try {
|
try {
|
||||||
$fh = $backend->get('config');
|
$fh = $backend->get('config');
|
||||||
} catch (BackendException) {
|
} catch (BackendException) {
|
||||||
@@ -64,6 +98,15 @@ final class Repository {
|
|||||||
if (($config['format'] ?? 0) !== self::FORMAT) {
|
if (($config['format'] ?? 0) !== self::FORMAT) {
|
||||||
throw new RepositoryException('Unsupported repository format');
|
throw new RepositoryException('Unsupported repository format');
|
||||||
}
|
}
|
||||||
|
return $config;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function id(): string {
|
||||||
|
return $this->repositoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function open(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||||
|
$config = self::readConfig($backend);
|
||||||
$repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']);
|
$repo = new self($backend, KeyRing::unwrap($config['key'], $passphrase), $config['id']);
|
||||||
$repo->index->load();
|
$repo->index->load();
|
||||||
return $repo;
|
return $repo;
|
||||||
@@ -272,7 +315,7 @@ final class Repository {
|
|||||||
private function storeBlob(string $data, PackWriter $packs, array &$stats): string {
|
private function storeBlob(string $data, PackWriter $packs, array &$stats): string {
|
||||||
$id = $this->keys->blobId($data);
|
$id = $this->keys->blobId($data);
|
||||||
if ($this->index->has($id)) {
|
if ($this->index->has($id)) {
|
||||||
$stats['dupBlobs']++;
|
$stats['dupBlobs'] = ($stats['dupBlobs'] ?? 0) + 1;
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
$flags = 0;
|
$flags = 0;
|
||||||
@@ -282,8 +325,8 @@ final class Repository {
|
|||||||
$payload = $deflated;
|
$payload = $deflated;
|
||||||
$flags |= self::FLAG_DEFLATE;
|
$flags |= self::FLAG_DEFLATE;
|
||||||
}
|
}
|
||||||
$stats['uploaded'] += $packs->add($id, $this->cipher->encryptString($payload, 'blob:' . $id), strlen($data), $flags);
|
$stats['uploaded'] = ($stats['uploaded'] ?? 0) + $packs->add($id, $this->cipher->encryptString($payload, 'blob:' . $id), strlen($data), $flags);
|
||||||
$stats['newBlobs']++;
|
$stats['newBlobs'] = ($stats['newBlobs'] ?? 0) + 1;
|
||||||
return $id;
|
return $id;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +350,23 @@ final class Repository {
|
|||||||
return $map;
|
return $map;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> entries of a snapshot, in stored order */
|
||||||
|
public function entries(string $snapshotId): \Generator {
|
||||||
|
return $this->streamEntries($snapshotId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The meta record (first line) of a snapshot. */
|
||||||
|
public function snapshotMeta(string $snapshotId): array {
|
||||||
|
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||||
|
$fh = $this->backend->get('snapshots/' . $snapshotId);
|
||||||
|
$this->cipher->decrypt($fh, $plain, 'snapshot:' . $snapshotId);
|
||||||
|
fclose($fh);
|
||||||
|
rewind($plain);
|
||||||
|
$meta = json_decode((string)fgets($plain), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
fclose($plain);
|
||||||
|
return $meta;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
|
/** @return \Generator<array{p:string, s:int, m:int, b:list<string>}> */
|
||||||
private function streamEntries(string $snapshotId): \Generator {
|
private function streamEntries(string $snapshotId): \Generator {
|
||||||
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
$plain = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Db\DbDumper;
|
||||||
|
use OCA\NgBackup\Db\Run;
|
||||||
|
use OCA\NgBackup\Db\RunMapper;
|
||||||
|
use OCA\NgBackup\Db\Snapshot;
|
||||||
|
use OCA\NgBackup\Db\SnapshotMapper;
|
||||||
|
use OCA\NgBackup\Db\Target;
|
||||||
|
use OCA\NgBackup\Job\BackupRun;
|
||||||
|
use OCP\App\IAppManager;
|
||||||
|
use OCP\IConfig;
|
||||||
|
use OCP\IDBConnection;
|
||||||
|
use OCP\Lock\ILockingProvider;
|
||||||
|
use OCP\Lock\LockedException;
|
||||||
|
use Psr\Log\LoggerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full backups of this installation: database (one consistent transaction), config and the
|
||||||
|
* data directory, as a resumable run whose state is stored in ngb_runs after every step.
|
||||||
|
*
|
||||||
|
* Excluded by default: previews, log files, the updater's backups and each user's cache and
|
||||||
|
* temporary uploads (all regenerable or temporary).
|
||||||
|
*/
|
||||||
|
class BackupService {
|
||||||
|
public const KIND_FULL = 'full';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private KeyService $keys,
|
||||||
|
private RunMapper $runs,
|
||||||
|
private SnapshotMapper $snapshots,
|
||||||
|
private IConfig $config,
|
||||||
|
private IDBConnection $db,
|
||||||
|
private IAppManager $appManager,
|
||||||
|
private ILockingProvider $locking,
|
||||||
|
private LoggerInterface $logger,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function start(Target $target, string $label = ''): Run {
|
||||||
|
if ($this->keys->recoveryKitConfirmation() === null) {
|
||||||
|
throw new \RuntimeException('Confirm the recovery kit first (occ backup:key:confirm)');
|
||||||
|
}
|
||||||
|
if ($this->config->getSystemValue('objectstore', null) !== null) {
|
||||||
|
throw new \RuntimeException('Object storage as primary storage is not supported yet');
|
||||||
|
}
|
||||||
|
$running = $this->runs->findRunning($target->getId());
|
||||||
|
if ($running !== []) {
|
||||||
|
return $running[0];
|
||||||
|
}
|
||||||
|
$dataDir = rtrim($this->config->getSystemValueString('datadirectory', \OC::$SERVERROOT . '/data'), '/');
|
||||||
|
$parent = $this->snapshots->latest($target->getId(), self::KIND_FULL);
|
||||||
|
|
||||||
|
$files = BackupRun::start(
|
||||||
|
['config' => rtrim(\OC::$configDir, '/'), 'data' => $dataDir],
|
||||||
|
$parent?->getSnapshotId(),
|
||||||
|
$label,
|
||||||
|
$this->excludes($dataDir),
|
||||||
|
['kind' => self::KIND_FULL, 'nextcloud' => $this->config->getSystemValueString('version'),
|
||||||
|
'instanceid' => $this->config->getSystemValueString('instanceid'),
|
||||||
|
'dbtype' => $this->config->getSystemValueString('dbtype'),
|
||||||
|
'apps' => $this->installedApps()],
|
||||||
|
);
|
||||||
|
|
||||||
|
$run = new Run();
|
||||||
|
$run->setTargetId($target->getId());
|
||||||
|
$run->setKind(self::KIND_FULL);
|
||||||
|
$run->setStatus(Run::RUNNING);
|
||||||
|
$run->setPhase('db');
|
||||||
|
$run->setState(json_encode(['files' => $files], JSON_THROW_ON_ERROR));
|
||||||
|
$run->setSnapshotId($files['snapshot']);
|
||||||
|
$run->setStartedAt(time());
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
return $this->runs->insert($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Do one step of a run until $deadline. Safe to call concurrently: a second caller finds the
|
||||||
|
* run locked and returns immediately.
|
||||||
|
*/
|
||||||
|
public function step(Run $run, float $deadline): Run {
|
||||||
|
$lockKey = 'ng_backup/run/' . $run->getId();
|
||||||
|
try {
|
||||||
|
$this->locking->acquireLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
|
} catch (LockedException) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$run = $this->runs->find($run->getId()); // fresh state after acquiring the lock
|
||||||
|
if ($run->getStatus() !== Run::RUNNING) {
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
$target = $this->targets->get((string)$run->getTargetId());
|
||||||
|
$repo = $this->targets->repository($target);
|
||||||
|
$state = json_decode($run->getState(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
|
||||||
|
if ($run->getPhase() === 'db') {
|
||||||
|
@set_time_limit(0); // the dump is one transaction; it cannot be split across requests yet
|
||||||
|
$dbStats = [];
|
||||||
|
$manifest = (new DbDumper($this->db->getInner(), $this->config->getSystemValueString('dbtableprefix', 'oc_')))->dump($repo, $dbStats);
|
||||||
|
$repo->putObject('db/' . $state['files']['snapshot'], json_encode($manifest, JSON_THROW_ON_ERROR));
|
||||||
|
$state['files']['meta']['db'] = 'db/' . $state['files']['snapshot'];
|
||||||
|
$state['dbStats'] = $dbStats + ['tables' => count($manifest['tables']),
|
||||||
|
'rows' => array_sum(array_column($manifest['tables'], 'rows'))];
|
||||||
|
$run->setPhase('files');
|
||||||
|
} else {
|
||||||
|
$state['files'] = BackupRun::step($repo, $state['files'], $deadline);
|
||||||
|
if ($state['files']['phase'] === 'done') {
|
||||||
|
$this->recordSnapshot($run, $state['files']);
|
||||||
|
$run->setStatus(Run::DONE);
|
||||||
|
$run->setPhase('done');
|
||||||
|
$run->setFinishedAt(time());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$run->setState(json_encode($state, JSON_THROW_ON_ERROR));
|
||||||
|
$run->setStats(json_encode(['db' => $state['dbStats'] ?? null, 'files' => $state['files']['stats']], JSON_THROW_ON_ERROR));
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
return $this->runs->update($run);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->logger->error('NG Backup run ' . $run->getId() . ' failed: ' . $e->getMessage(), ['exception' => $e]);
|
||||||
|
$run->setStatus(Run::FAILED);
|
||||||
|
$run->setError($e->getMessage());
|
||||||
|
$run->setFinishedAt(time());
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
return $this->runs->update($run);
|
||||||
|
} finally {
|
||||||
|
$this->locking->releaseLock($lockKey, ILockingProvider::LOCK_EXCLUSIVE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run to completion in this process (occ, system cron), reporting progress. */
|
||||||
|
public function runToCompletion(Run $run, ?callable $progress = null, float $stepSeconds = 30): Run {
|
||||||
|
while ($run->getStatus() === Run::RUNNING) {
|
||||||
|
$run = $this->step($run, microtime(true) + $stepSeconds);
|
||||||
|
if ($progress !== null) {
|
||||||
|
$progress($run);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $run;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function cancel(Run $run): Run {
|
||||||
|
$run->setStatus(Run::CANCELLED);
|
||||||
|
$run->setFinishedAt(time());
|
||||||
|
$run->setUpdatedAt(time());
|
||||||
|
return $this->runs->update($run);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function recordSnapshot(Run $run, array $files): void {
|
||||||
|
$s = new Snapshot();
|
||||||
|
$s->setTargetId($run->getTargetId());
|
||||||
|
$s->setSnapshotId($files['snapshot']);
|
||||||
|
$s->setKind($run->getKind());
|
||||||
|
$s->setLabel($files['label'] !== '' ? $files['label'] : null);
|
||||||
|
$s->setCreatedAt(time());
|
||||||
|
$s->setFiles($files['stats']['files']);
|
||||||
|
$s->setBytes($files['stats']['bytes']);
|
||||||
|
$this->snapshots->insert($s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function excludes(string $dataDir): array {
|
||||||
|
$instanceId = $this->config->getSystemValueString('instanceid');
|
||||||
|
$exclude = [
|
||||||
|
"data/appdata_$instanceId/preview",
|
||||||
|
"data/updater-$instanceId",
|
||||||
|
'data/nextcloud.log',
|
||||||
|
'data/nextcloud.log.*',
|
||||||
|
'data/audit.log',
|
||||||
|
'data/*/cache',
|
||||||
|
'data/*/uploads',
|
||||||
|
];
|
||||||
|
$logFile = $this->config->getSystemValueString('logfile', '');
|
||||||
|
if ($logFile !== '' && str_starts_with($logFile, $dataDir . '/')) {
|
||||||
|
$exclude[] = 'data/' . substr($logFile, strlen($dataDir) + 1);
|
||||||
|
}
|
||||||
|
// Never back up a local backup location into itself.
|
||||||
|
foreach ($this->targets->list() as $t) {
|
||||||
|
$opts = $this->targets->options($t);
|
||||||
|
$dir = rtrim((string)($opts['datadir'] ?? ''), '/');
|
||||||
|
if ($t->getBackend() === 'local' && $dir !== '' && str_starts_with($dir . '/', $dataDir . '/')) {
|
||||||
|
$exclude[] = trim('data/' . substr($dir, strlen($dataDir) + 1) . '/' . $t->getBasePath(), '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array_values(array_unique($exclude));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> app id => version */
|
||||||
|
private function installedApps(): array {
|
||||||
|
$apps = [];
|
||||||
|
foreach ($this->appManager->getEnabledApps() as $app) {
|
||||||
|
$apps[$app] = $this->appManager->getAppVersion($app);
|
||||||
|
}
|
||||||
|
ksort($apps);
|
||||||
|
return $apps;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\AppInfo\Application;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCP\IAppConfig;
|
||||||
|
use OCP\Security\ICrypto;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The installation's backup master key.
|
||||||
|
*
|
||||||
|
* - The server keeps the master key encrypted with Nextcloud's own secret (ICrypto), so
|
||||||
|
* scheduled backups run without anyone typing a passphrase. Whoever fully controls the server
|
||||||
|
* can therefore read the backups; whoever only gets the backup location cannot.
|
||||||
|
* - A copy wrapped with the admin's passphrase (Argon2id) goes into every repository config and
|
||||||
|
* into the recovery kit, so backups can be restored on a fresh server with the passphrase.
|
||||||
|
* - The admin must confirm having stored the recovery kit before the first backup runs.
|
||||||
|
*/
|
||||||
|
class KeyService {
|
||||||
|
private const KEY_MASTER = 'master_key';
|
||||||
|
private const KEY_WRAPPED = 'master_key_wrapped';
|
||||||
|
private const KEY_CONFIRMED = 'recovery_kit_confirmed';
|
||||||
|
private const KEY_FINGERPRINT = 'master_key_fingerprint';
|
||||||
|
|
||||||
|
private ?KeyRing $cached = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IAppConfig $appConfig,
|
||||||
|
private ICrypto $crypto,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isInitialized(): bool {
|
||||||
|
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true) !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function initialize(#[\SensitiveParameter] string $passphrase): void {
|
||||||
|
if ($this->isInitialized()) {
|
||||||
|
throw new \RuntimeException('A backup key already exists');
|
||||||
|
}
|
||||||
|
if (strlen($passphrase) < 12) {
|
||||||
|
throw new \InvalidArgumentException('The passphrase must be at least 12 characters');
|
||||||
|
}
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::KEY_MASTER,
|
||||||
|
$this->crypto->encrypt(base64_encode($keys->exportMasterKey())), true, true);
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::KEY_WRAPPED,
|
||||||
|
json_encode($keys->wrap($passphrase), JSON_THROW_ON_ERROR), true, true);
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::KEY_FINGERPRINT, $this->fingerprintOf($keys), true);
|
||||||
|
$this->appConfig->deleteKey(Application::APP_ID, self::KEY_CONFIRMED);
|
||||||
|
$this->cached = $keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function keyRing(): KeyRing {
|
||||||
|
if ($this->cached === null) {
|
||||||
|
$enc = $this->appConfig->getValueString(Application::APP_ID, self::KEY_MASTER, '', true);
|
||||||
|
if ($enc === '') {
|
||||||
|
throw new \RuntimeException('No backup key yet: run occ backup:key:init');
|
||||||
|
}
|
||||||
|
$this->cached = new KeyRing(base64_decode($this->crypto->decrypt($enc), true));
|
||||||
|
}
|
||||||
|
return $this->cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Passphrase-wrapped copy of the master key (for repository configs and the recovery kit). */
|
||||||
|
public function wrappedKey(): array {
|
||||||
|
return json_decode($this->appConfig->getValueString(Application::APP_ID, self::KEY_WRAPPED, '{}', true), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Short identifier of the key, shown in the UI and the kit (not secret). */
|
||||||
|
public function fingerprint(): string {
|
||||||
|
return $this->appConfig->getValueString(Application::APP_ID, self::KEY_FINGERPRINT, '', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{version:int, app:string, fingerprint:string, created:string, wrapped_key:array, instructions:string} */
|
||||||
|
public function recoveryKit(): array {
|
||||||
|
return [
|
||||||
|
'version' => 1,
|
||||||
|
'app' => 'NG Backup',
|
||||||
|
'fingerprint' => $this->fingerprint(),
|
||||||
|
'created' => gmdate('c'),
|
||||||
|
'wrapped_key' => $this->wrappedKey(),
|
||||||
|
'instructions' => 'Keep this file and your passphrase safe, outside this server. To restore on a new server: install NG Backup, '
|
||||||
|
. 'add the backup location, and enter this kit with your passphrase. Without both, the backups cannot be decrypted.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function confirmRecoveryKit(string $uid): void {
|
||||||
|
$this->appConfig->setValueString(Application::APP_ID, self::KEY_CONFIRMED,
|
||||||
|
json_encode(['uid' => $uid, 'time' => time(), 'fingerprint' => $this->fingerprint()], JSON_THROW_ON_ERROR), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Confirmation for the current key, or null. */
|
||||||
|
public function recoveryKitConfirmation(): ?array {
|
||||||
|
$raw = $this->appConfig->getValueString(Application::APP_ID, self::KEY_CONFIRMED, '', true);
|
||||||
|
$data = $raw === '' ? null : json_decode($raw, true);
|
||||||
|
return ($data !== null && ($data['fingerprint'] ?? '') === $this->fingerprint()) ? $data : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fingerprintOf(KeyRing $keys): string {
|
||||||
|
$id = $keys->blobId('ng_backup key fingerprint');
|
||||||
|
return implode('-', str_split(substr($id, 0, 16), 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Db\Target;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCP\Files\File;
|
||||||
|
use OCP\Files\Folder;
|
||||||
|
use OCP\Files\IRootFolder;
|
||||||
|
use OCP\Files\NotFoundException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restoring from a snapshot.
|
||||||
|
*
|
||||||
|
* User files (data/<uid>/files/...) are written through the Nextcloud Files API, so the file
|
||||||
|
* cache, versions and trash stay consistent: an overwritten file becomes a version and a
|
||||||
|
* removed file goes to the trash, which makes a restore itself reversible. Every file is
|
||||||
|
* written to a temporary name first and only renamed into place after all its blobs were
|
||||||
|
* decrypted and verified.
|
||||||
|
*
|
||||||
|
* Modes for user files:
|
||||||
|
* - new-folder (default): into "Restored <date>/<same path>", nothing existing is touched
|
||||||
|
* - merge: into the original place, existing files are overwritten (old content kept as a version)
|
||||||
|
* - replace: like merge, and files that are not in the snapshot are moved to the trash
|
||||||
|
*/
|
||||||
|
class RestoreService {
|
||||||
|
public const MODE_NEW_FOLDER = 'new-folder';
|
||||||
|
public const MODE_MERGE = 'merge';
|
||||||
|
public const MODE_REPLACE = 'replace';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private TargetService $targets,
|
||||||
|
private IRootFolder $root,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Direct children (files and folders) of a path in a snapshot.
|
||||||
|
*
|
||||||
|
* @return list<array{name:string, type:string, size:int, mtime:int|null, files:int}>
|
||||||
|
*/
|
||||||
|
public function browse(Target $target, string $snapshotId, string $path = ''): array {
|
||||||
|
$prefix = trim($path, '/');
|
||||||
|
$prefix = $prefix === '' ? '' : $prefix . '/';
|
||||||
|
$children = [];
|
||||||
|
foreach ($this->targets->repository($target)->entries($snapshotId) as $e) {
|
||||||
|
if ($prefix !== '' && !str_starts_with($e['p'], $prefix)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$rest = substr($e['p'], strlen($prefix));
|
||||||
|
[$name, $more] = array_pad(explode('/', $rest, 2), 2, null);
|
||||||
|
$c = $children[$name] ?? ['name' => $name, 'type' => $more === null ? 'file' : 'dir', 'size' => 0, 'mtime' => null, 'files' => 0];
|
||||||
|
$c['size'] += $e['s'];
|
||||||
|
$c['files']++;
|
||||||
|
if ($more === null) {
|
||||||
|
$c['mtime'] = $e['m'];
|
||||||
|
}
|
||||||
|
$children[$name] = $c;
|
||||||
|
}
|
||||||
|
ksort($children, SORT_STRING);
|
||||||
|
return array_values($children);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore user files from "data/<uid>/files[/<path>]" into that user's Nextcloud files.
|
||||||
|
*
|
||||||
|
* @return array{restored:int, trashed:int, target:string}
|
||||||
|
*/
|
||||||
|
public function restoreUserFiles(Target $target, string $snapshotId, string $logicalPath, string $mode = self::MODE_NEW_FOLDER, ?callable $progress = null): array {
|
||||||
|
if (!preg_match('#^data/([^/]+)/files(?:/(.*))?$#', trim($logicalPath, '/'), $m)) {
|
||||||
|
throw new \InvalidArgumentException('Expected a path like data/<user>/files/<folder or file>');
|
||||||
|
}
|
||||||
|
[$uid, $rel] = [$m[1], trim($m[2] ?? '', '/')];
|
||||||
|
$userFolder = $this->root->getUserFolder($uid);
|
||||||
|
$repo = $this->targets->repository($target);
|
||||||
|
$source = 'data/' . $uid . '/files' . ($rel === '' ? '' : '/' . $rel);
|
||||||
|
|
||||||
|
$entries = [];
|
||||||
|
$single = false;
|
||||||
|
foreach ($repo->entries($snapshotId) as $e) {
|
||||||
|
if ($e['p'] === $source) {
|
||||||
|
$single = true;
|
||||||
|
$entries[''] = $e;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (str_starts_with($e['p'], $source . '/')) {
|
||||||
|
$entries[substr($e['p'], strlen($source) + 1)] = $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($entries === []) {
|
||||||
|
throw new NotFoundException("Nothing at $source in snapshot $snapshotId");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Where to write. new-folder mirrors the path under "Restored <date>" in the user's root.
|
||||||
|
$parentRel = $single ? (dirname($rel) === '.' ? '' : dirname($rel)) : $rel;
|
||||||
|
if ($mode === self::MODE_NEW_FOLDER) {
|
||||||
|
$parentRel = trim('Restored ' . date('Y-m-d H.i') . '/' . $parentRel, '/');
|
||||||
|
} elseif ($mode !== self::MODE_MERGE && $mode !== self::MODE_REPLACE) {
|
||||||
|
throw new \InvalidArgumentException("Unknown mode $mode");
|
||||||
|
}
|
||||||
|
$dest = $this->ensureFolder($userFolder, $parentRel);
|
||||||
|
$names = $single ? [basename($rel) => $entries['']] : $entries;
|
||||||
|
|
||||||
|
$restored = 0;
|
||||||
|
foreach ($names as $relPath => $entry) {
|
||||||
|
$this->writeFile($repo, $dest, (string)$relPath, $entry);
|
||||||
|
$restored++;
|
||||||
|
if ($progress !== null) {
|
||||||
|
$progress($restored, count($names), (string)$relPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$trashed = 0;
|
||||||
|
if ($mode === self::MODE_REPLACE && !$single) {
|
||||||
|
$trashed = $this->trashExtras($dest, '', array_flip(array_keys($names)));
|
||||||
|
}
|
||||||
|
return ['restored' => $restored, 'trashed' => $trashed, 'target' => $uid . ':' . $userFolder->getRelativePath($dest->getPath())];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Raw restore of any snapshot path to a local directory (administrators, disaster recovery). */
|
||||||
|
public function restoreToDirectory(Target $target, string $snapshotId, string $prefix, string $directory): int {
|
||||||
|
if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) {
|
||||||
|
throw new \RuntimeException("Cannot create $directory");
|
||||||
|
}
|
||||||
|
return $this->targets->repository($target)->restore($snapshotId, $directory, trim($prefix, '/') === '' ? '' : trim($prefix, '/') . '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function writeFile(Repository $repo, Folder $dest, string $relPath, array $entry): void {
|
||||||
|
$dir = dirname($relPath);
|
||||||
|
$folder = $dir === '.' ? $dest : $this->ensureFolder($dest, $dir);
|
||||||
|
$name = basename($relPath);
|
||||||
|
// Stage under a temporary name; only rename into place after every blob was verified.
|
||||||
|
$tmpName = '.' . $name . '.ngb-restore-' . bin2hex(random_bytes(3));
|
||||||
|
$tmp = $folder->newFile($tmpName);
|
||||||
|
try {
|
||||||
|
$out = $tmp->fopen('wb');
|
||||||
|
$written = 0;
|
||||||
|
foreach ($repo->readBlobs($entry['b']) as $data) { // each blob is verified on read
|
||||||
|
if (fwrite($out, $data) !== strlen($data)) {
|
||||||
|
throw new \RuntimeException('Write failed for ' . $relPath);
|
||||||
|
}
|
||||||
|
$written += strlen($data);
|
||||||
|
}
|
||||||
|
if (!fclose($out)) {
|
||||||
|
throw new \RuntimeException('Write failed for ' . $relPath);
|
||||||
|
}
|
||||||
|
if ($written !== $entry['s']) {
|
||||||
|
throw new \RuntimeException("Size mismatch restoring $relPath");
|
||||||
|
}
|
||||||
|
if ($folder->nodeExists($name)) {
|
||||||
|
$existing = $folder->get($name);
|
||||||
|
if (!$existing instanceof File) {
|
||||||
|
throw new \RuntimeException("$relPath exists and is not a file");
|
||||||
|
}
|
||||||
|
// Overwrite through the Files API: the current content becomes a version.
|
||||||
|
$in = $tmp->fopen('rb');
|
||||||
|
$existing->putContent($in);
|
||||||
|
if (is_resource($in)) {
|
||||||
|
fclose($in);
|
||||||
|
}
|
||||||
|
$tmp->delete();
|
||||||
|
$existing->touch($entry['m']);
|
||||||
|
} else {
|
||||||
|
$tmp->move($folder->getPath() . '/' . $name);
|
||||||
|
$folder->get($name)->touch($entry['m']);
|
||||||
|
}
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
try {
|
||||||
|
$tmp->delete();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function trashExtras(Folder $folder, string $prefix, array $keep): int {
|
||||||
|
$count = 0;
|
||||||
|
foreach ($folder->getDirectoryListing() as $node) {
|
||||||
|
$rel = $prefix === '' ? $node->getName() : $prefix . '/' . $node->getName();
|
||||||
|
if ($node instanceof Folder) {
|
||||||
|
$hasKept = false;
|
||||||
|
foreach (array_keys($keep) as $k) {
|
||||||
|
if (str_starts_with((string)$k, $rel . '/')) {
|
||||||
|
$hasKept = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($hasKept) {
|
||||||
|
$count += $this->trashExtras($node, $rel, $keep);
|
||||||
|
} else {
|
||||||
|
$node->delete(); // to the trash
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
} elseif (!isset($keep[$rel])) {
|
||||||
|
$node->delete();
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ensureFolder(Folder $base, string $path): Folder {
|
||||||
|
$path = trim($path, '/');
|
||||||
|
if ($path === '' || $path === '.') {
|
||||||
|
return $base;
|
||||||
|
}
|
||||||
|
if ($base->nodeExists($path)) {
|
||||||
|
$node = $base->get($path);
|
||||||
|
if (!$node instanceof Folder) {
|
||||||
|
throw new \RuntimeException("$path exists and is not a folder");
|
||||||
|
}
|
||||||
|
return $node;
|
||||||
|
}
|
||||||
|
return $base->newFolder($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Service;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\ExternalStorageFactory;
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\StorageBackend;
|
||||||
|
use OCA\NgBackup\Db\Target;
|
||||||
|
use OCA\NgBackup\Db\TargetMapper;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCP\App\IAppManager;
|
||||||
|
use OCP\Security\ICrypto;
|
||||||
|
use OCP\Server;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup targets: a files_external backend + auth mechanism + options + base path. The storage
|
||||||
|
* object is built only while it is used; nothing is mounted. files_external is loaded for its
|
||||||
|
* classes but its enabled/disabled state is left as the admin set it.
|
||||||
|
*/
|
||||||
|
class TargetService {
|
||||||
|
public function __construct(
|
||||||
|
private TargetMapper $mapper,
|
||||||
|
private KeyService $keys,
|
||||||
|
private ICrypto $crypto,
|
||||||
|
private IAppManager $appManager,
|
||||||
|
private \OCA\NgBackup\Db\DbIndexCache $indexCache,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{id:string, name:string, auth:list<string>, parameters:array}> */
|
||||||
|
public function availableBackends(): array {
|
||||||
|
return $this->factory()->describeBackends();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a target: connect, create the repository there (or adopt an existing one made with this
|
||||||
|
* installation's key), then save it.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $options
|
||||||
|
* @return array{target: Target, created: bool}
|
||||||
|
*/
|
||||||
|
public function add(string $name, string $backend, string $auth, array $options, string $basePath): array {
|
||||||
|
if (!preg_match('/^[A-Za-z0-9][A-Za-z0-9 _.-]{0,63}$/', $name)) {
|
||||||
|
throw new \InvalidArgumentException('Name: letters, digits, space, _ . - (max 64)');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$this->mapper->findByName($name);
|
||||||
|
throw new \InvalidArgumentException("A target named '$name' already exists");
|
||||||
|
} catch (\OCP\AppFramework\Db\DoesNotExistException) {
|
||||||
|
}
|
||||||
|
$keys = $this->keys->keyRing();
|
||||||
|
$storageBackend = $this->buildBackend($backend, $auth, $options, $basePath);
|
||||||
|
if ($storageBackend->exists('config')) {
|
||||||
|
$repo = Repository::openWithKey($storageBackend, $keys);
|
||||||
|
$created = false;
|
||||||
|
} else {
|
||||||
|
$repo = Repository::initWithKey($storageBackend, $keys, $this->keys->wrappedKey());
|
||||||
|
$created = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->mapper->findAll() as $existing) {
|
||||||
|
if ($existing->getRepositoryId() === $repo->id()) {
|
||||||
|
throw new \InvalidArgumentException("This repository is already registered as '{$existing->getName()}'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$target = new Target();
|
||||||
|
$target->setName($name);
|
||||||
|
$target->setBackend($backend);
|
||||||
|
$target->setAuth($auth);
|
||||||
|
$target->setOptions($this->crypto->encrypt(json_encode($options, JSON_THROW_ON_ERROR)));
|
||||||
|
$target->setBasePath(trim($basePath, '/'));
|
||||||
|
$target->setRepositoryId($repo->id());
|
||||||
|
$target->setCreatedAt(time());
|
||||||
|
return ['target' => $this->mapper->insert($target), 'created' => $created];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Target> */
|
||||||
|
public function list(): array {
|
||||||
|
return $this->mapper->findAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $nameOrId): Target {
|
||||||
|
return ctype_digit($nameOrId) ? $this->mapper->find((int)$nameOrId) : $this->mapper->findByName($nameOrId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Remove the target from NG Backup. The backups on the target itself are not touched. */
|
||||||
|
public function remove(Target $target): void {
|
||||||
|
$this->mapper->delete($target);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function backend(Target $target): IBackend {
|
||||||
|
return $this->buildBackend($target->getBackend(), $target->getAuth(), $this->options($target), $target->getBasePath());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function repository(Target $target): Repository {
|
||||||
|
$repo = Repository::openWithKey($this->backend($target), $this->keys->keyRing(), $this->indexCache);
|
||||||
|
if ($target->getRepositoryId() !== null && $repo->id() !== $target->getRepositoryId()) {
|
||||||
|
throw new \RuntimeException('The target now contains a different repository than when it was added');
|
||||||
|
}
|
||||||
|
return $repo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function options(Target $target): array {
|
||||||
|
return json_decode($this->crypto->decrypt($target->getOptions()), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function buildBackend(string $backend, string $auth, array $options, string $basePath): StorageBackend {
|
||||||
|
return new StorageBackend($this->factory()->create($backend, $auth, $options), $basePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function factory(): ExternalStorageFactory {
|
||||||
|
// Load files_external's classes without enabling the app (its state stays as the admin set it).
|
||||||
|
if (!class_exists(\OCA\Files_External\Service\BackendService::class)) {
|
||||||
|
$this->appManager->loadApp('files_external');
|
||||||
|
}
|
||||||
|
return new ExternalStorageFactory(Server::get(\OCA\Files_External\Service\BackendService::class));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"version":2,"defects":{"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":7},"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.129,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.126,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.005,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.007,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.334,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#truncated":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#trailing data":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#cut at frame boundary":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#bad magic":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongAssociatedDataIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testWrongKeyIsRejected":0,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testRunInManyTinyStepsRestoresIdentically":1.428,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":1.902,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":1.951,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.018,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.184,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.001,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.027,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.056,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.074,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.258,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.514,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":2.848,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testOrderMatchesCompare":0.003,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testResumeAfterCursorYieldsTheRest":0.006,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testExcludeAndMultipleRoots":0.008,"OCA\\NgBackup\\Tests\\Unit\\Repository\\IndexCacheTest::testSecondOpenDownloadsNoIndexFiles":2.618}}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Crypto;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Crypto\CryptoException;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class KeyRingTest extends TestCase {
|
||||||
|
public function testWrapUnwrapKeepsKeys(): void {
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
$restored = KeyRing::unwrap($keys->wrap('pass phrase'), 'pass phrase');
|
||||||
|
$this->assertSame($keys->blobId('data'), $restored->blobId('data'));
|
||||||
|
$this->assertSame($keys->dataKey(), $restored->dataKey());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongPassphrase(): void {
|
||||||
|
$wrapped = KeyRing::generate()->wrap('right');
|
||||||
|
$this->expectException(CryptoException::class);
|
||||||
|
KeyRing::unwrap($wrapped, 'wrong');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlobIdsAreKeyed(): void {
|
||||||
|
$this->assertNotSame(KeyRing::generate()->blobId('same'), KeyRing::generate()->blobId('same'));
|
||||||
|
$k = KeyRing::generate();
|
||||||
|
$this->assertSame($k->blobId('same'), $k->blobId('same'));
|
||||||
|
$this->assertMatchesRegularExpression('/^[0-9a-f]{64}$/', $k->blobId('x'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRejectsShortMasterKey(): void {
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
new KeyRing('short');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Crypto;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Crypto\CryptoException;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Crypto\StreamCipher;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class StreamCipherTest extends TestCase {
|
||||||
|
private StreamCipher $cipher;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->cipher = new StreamCipher(KeyRing::generate());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function sizes(): array {
|
||||||
|
$f = StreamCipher::FRAME_SIZE;
|
||||||
|
return ['empty' => [0], 'small' => [100], 'one frame' => [$f], 'frame+1' => [$f + 1], 'several' => [3 * $f + 17]];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('sizes')]
|
||||||
|
public function testRoundTrip(int $size): void {
|
||||||
|
$plain = random_bytes(max(1, $size));
|
||||||
|
$plain = substr($plain, 0, $size);
|
||||||
|
$this->assertSame($plain, $this->cipher->decryptString($this->cipher->encryptString($plain, 'ad'), 'ad'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStreamingKeepsMemoryBounded(): void {
|
||||||
|
$in = fopen('php://temp', 'w+b');
|
||||||
|
for ($i = 0; $i < 32; $i++) {
|
||||||
|
fwrite($in, random_bytes(1048576));
|
||||||
|
}
|
||||||
|
rewind($in);
|
||||||
|
$enc = fopen('php://temp', 'w+b');
|
||||||
|
memory_reset_peak_usage();
|
||||||
|
$base = memory_get_usage();
|
||||||
|
$this->cipher->encrypt($in, $enc);
|
||||||
|
$this->assertLessThan(4 * 1048576, memory_get_peak_usage() - $base);
|
||||||
|
rewind($enc);
|
||||||
|
$out = fopen('php://temp', 'w+b');
|
||||||
|
$this->assertSame(32 * 1048576, $this->cipher->decrypt($enc, $out));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function corruptions(): array {
|
||||||
|
return [
|
||||||
|
'flipped byte' => [static function (string $c): string { $c[60] = chr(ord($c[60]) ^ 1); return $c; }],
|
||||||
|
'truncated' => [static fn (string $c): string => substr($c, 0, -10)],
|
||||||
|
'trailing data' => [static fn (string $c): string => $c . 'x'],
|
||||||
|
'cut at frame boundary' => [static fn (string $c): string => substr($c, 0, 4 + 24 + 4 + StreamCipher::FRAME_SIZE + 17)],
|
||||||
|
'bad magic' => [static fn (string $c): string => 'XXXX' . substr($c, 4)],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('corruptions')]
|
||||||
|
public function testCorruptionIsRejected(callable $corrupt): void {
|
||||||
|
$cipher = $this->cipher->encryptString(str_repeat('a', StreamCipher::FRAME_SIZE + 500), 'ad');
|
||||||
|
$this->expectException(CryptoException::class);
|
||||||
|
$this->cipher->decryptString($corrupt($cipher), 'ad');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongAssociatedDataIsRejected(): void {
|
||||||
|
$this->expectException(CryptoException::class);
|
||||||
|
$this->cipher->decryptString($this->cipher->encryptString('x', 'blob:1'), 'blob:2');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongKeyIsRejected(): void {
|
||||||
|
$this->expectException(CryptoException::class);
|
||||||
|
(new StreamCipher(KeyRing::generate()))->decryptString($this->cipher->encryptString('x'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Job;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Job\BackupRun;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class BackupRunTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
public function testRunInManyTinyStepsRestoresIdentically(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
mkdir("$src/a", 0700, true);
|
||||||
|
for ($i = 0; $i < 50; $i++) {
|
||||||
|
file_put_contents("$src/a/f$i.txt", random_bytes(20000));
|
||||||
|
}
|
||||||
|
file_put_contents("$src/big.bin", random_bytes(13 * 1048576));
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
|
||||||
|
$state = BackupRun::start($src);
|
||||||
|
$steps = 0;
|
||||||
|
while ($state['phase'] !== 'done' && $steps < 500) {
|
||||||
|
// Fresh repository object per step, state through JSON: as separate processes would.
|
||||||
|
$repo = Repository::openWithKey($backend, $keys);
|
||||||
|
$state = json_decode(json_encode(BackupRun::step($repo, $state, microtime(true) + 0.05)), true);
|
||||||
|
$steps++;
|
||||||
|
}
|
||||||
|
$this->assertSame('done', $state['phase']);
|
||||||
|
$this->assertGreaterThan(2, $steps, 'the run was actually split into steps');
|
||||||
|
|
||||||
|
$target = $this->tempDir();
|
||||||
|
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
|
||||||
|
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFileChangedBetweenStepsIsReadAgain(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
file_put_contents("$src/big.bin", random_bytes(20 * 1048576));
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
|
||||||
|
$state = BackupRun::start($src);
|
||||||
|
$mutated = false;
|
||||||
|
for ($i = 0; $i < 500 && $state['phase'] !== 'done'; $i++) {
|
||||||
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.01);
|
||||||
|
if (!$mutated && $state['cur'] !== null && $state['cur']['offset'] > 0) {
|
||||||
|
$fh = fopen("$src/big.bin", 'r+b');
|
||||||
|
fwrite($fh, random_bytes(1024)); // same size, new content at the start
|
||||||
|
fclose($fh);
|
||||||
|
touch("$src/big.bin", time() + 10);
|
||||||
|
$mutated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->assertTrue($mutated);
|
||||||
|
$this->assertGreaterThanOrEqual(1, $state['stats']['retried'] ?? 0);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
Repository::openWithKey($backend, $keys)->restore($state['snapshot'], $target);
|
||||||
|
$this->assertSame(self::hashTree($src), self::hashTree($target));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIncrementalRunInTinyStepsReadsNothing(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 200; $i++) {
|
||||||
|
@mkdir("$src/d" . ($i % 7), 0700, true);
|
||||||
|
file_put_contents("$src/d" . ($i % 7) . "/f$i", random_bytes(3000));
|
||||||
|
}
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
$run = function (?string $parent) use ($backend, $src, $keys): array {
|
||||||
|
$state = BackupRun::start(['data' => $src], $parent);
|
||||||
|
for ($i = 0; $i < 2000 && $state['phase'] !== 'done'; $i++) {
|
||||||
|
$state = json_decode(json_encode(BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true))), true);
|
||||||
|
}
|
||||||
|
return $state;
|
||||||
|
};
|
||||||
|
$first = $run(null);
|
||||||
|
$second = $run($first['snapshot']);
|
||||||
|
$this->assertSame('done', $second['phase']);
|
||||||
|
$this->assertGreaterThan(150, $second["steps"], "one file per step");
|
||||||
|
$this->assertSame(0, $second['stats']['read'], 'merge-join with the parent across steps reuses every file');
|
||||||
|
$this->assertSame(200, $second['stats']['reused']);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
Repository::openWithKey($backend, $keys)->restore($second['snapshot'], $target);
|
||||||
|
$this->assertSame(self::hashTree($src), self::hashTree("$target/data"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Job;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Job\TreeWalker;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class TreeWalkerTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private function tree(): string {
|
||||||
|
$d = $this->tempDir();
|
||||||
|
foreach (['a.txt', 'a/x', 'a/b/y', 'a-b', 'b', 'skip/me', 'Z'] as $f) {
|
||||||
|
@mkdir(dirname("$d/$f"), 0700, true);
|
||||||
|
file_put_contents("$d/$f", $f);
|
||||||
|
}
|
||||||
|
return $d;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOrderMatchesCompare(): void {
|
||||||
|
$paths = array_keys(iterator_to_array((new TreeWalker(['' => $this->tree()]))->walk()));
|
||||||
|
$sorted = $paths;
|
||||||
|
usort($sorted, [TreeWalker::class, 'compare']);
|
||||||
|
$this->assertSame($sorted, $paths);
|
||||||
|
$this->assertSame(['Z', 'a', 'a-b', 'a.txt', 'b', 'skip'], array_values(array_unique(array_map(fn ($p) => explode('/', $p)[0], $paths))));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResumeAfterCursorYieldsTheRest(): void {
|
||||||
|
$w = new TreeWalker(['' => $this->tree()]);
|
||||||
|
$all = array_keys(iterator_to_array($w->walk()));
|
||||||
|
foreach ($all as $i => $cursor) {
|
||||||
|
$this->assertSame(array_slice($all, $i + 1), array_keys(iterator_to_array($w->walk($cursor))), "after $cursor");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testExcludeAndMultipleRoots(): void {
|
||||||
|
$d = $this->tree();
|
||||||
|
$c = $this->tempDir();
|
||||||
|
file_put_contents("$c/config.php", '<?php');
|
||||||
|
$paths = array_keys(iterator_to_array((new TreeWalker(['config' => $c, 'data' => $d], ['data/skip', 'data/b']))->walk()));
|
||||||
|
$this->assertContains('config/config.php', $paths);
|
||||||
|
$this->assertContains('data/a/b/y', $paths);
|
||||||
|
$this->assertNotContains('data/skip/me', $paths);
|
||||||
|
$this->assertNotContains('data/b', $paths);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Repository\IIndexCache;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class IndexCacheTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
public function testSecondOpenDownloadsNoIndexFiles(): void {
|
||||||
|
$cache = new class implements IIndexCache {
|
||||||
|
public array $data = [];
|
||||||
|
public function all(string $r): array { return $this->data[$r] ?? []; }
|
||||||
|
public function put(string $r, string $p, array $e): void { $this->data[$r][$p] = $e; }
|
||||||
|
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
||||||
|
};
|
||||||
|
$src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 12; $i++) {
|
||||||
|
file_put_contents("$src/f$i", random_bytes(3 * 1048576));
|
||||||
|
}
|
||||||
|
$backend = new LocalBackend($this->tempDir());
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
|
||||||
|
$repo = Repository::openWithKey($backend, $keys, $cache);
|
||||||
|
$r1 = $repo->backupDirectory($src);
|
||||||
|
$this->assertGreaterThan(0, count($backend->list('index')));
|
||||||
|
|
||||||
|
$again = Repository::openWithKey($backend, $keys, $cache);
|
||||||
|
$this->assertSame(0, $again->indexDownloads(), 'everything came from the cache');
|
||||||
|
|
||||||
|
file_put_contents("$src/new", random_bytes(2 * 1048576));
|
||||||
|
$again->backupDirectory($src, $r1['snapshot']);
|
||||||
|
$third = Repository::openWithKey($backend, $keys, $cache);
|
||||||
|
$this->assertSame(0, $third->indexDownloads(), 'packs written by this server are cached on write');
|
||||||
|
|
||||||
|
// A cache that misses entries (e.g. another server wrote packs) fetches only those.
|
||||||
|
$repoId = array_key_first($cache->data);
|
||||||
|
array_pop($cache->data[$repoId]);
|
||||||
|
$fourth = Repository::openWithKey($backend, $keys, $cache);
|
||||||
|
$this->assertSame(1, $fourth->indexDownloads());
|
||||||
|
|
||||||
|
$target = $this->tempDir();
|
||||||
|
$fourth->restore($r1['snapshot'], $target);
|
||||||
|
$this->assertCount(12, self::hashTree($target));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\CryptoException;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Repository\RepositoryException;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class RepositoryTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private string $src;
|
||||||
|
private LocalBackend $backend;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->src = $this->tempDir();
|
||||||
|
$this->backend = new LocalBackend($this->tempDir());
|
||||||
|
for ($i = 0; $i < 30; $i++) {
|
||||||
|
$this->write("docs/d" . ($i % 3) . "/note-$i.txt", str_repeat("regel $i ", 500));
|
||||||
|
}
|
||||||
|
$this->write('bin/random.bin', random_bytes(9 * 1048576 + 123)); // spans 3 blobs
|
||||||
|
$this->write('copies/a.bin', $same = random_bytes(5 * 1048576));
|
||||||
|
$this->write('copies/b.bin', $same);
|
||||||
|
$this->write('empty.txt', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function write(string $rel, string $data): void {
|
||||||
|
@mkdir(dirname($this->src . '/' . $rel), 0700, true);
|
||||||
|
file_put_contents($this->src . '/' . $rel, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBackupAndRestoreAreIdentical(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$r = $repo->backupDirectory($this->src);
|
||||||
|
$this->assertSame(34, $r['files']);
|
||||||
|
$this->assertGreaterThanOrEqual(2, $r['dupBlobs'], 'identical copies are stored once');
|
||||||
|
|
||||||
|
$target = $this->tempDir();
|
||||||
|
Repository::open($this->backend, 'pw')->restore($r['snapshot'], $target);
|
||||||
|
$this->assertSame(self::hashTree($this->src), self::hashTree($target));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnchangedRunReadsAndUploadsNothing(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$r1 = $repo->backupDirectory($this->src);
|
||||||
|
$r2 = $repo->backupDirectory($this->src, $r1['snapshot']);
|
||||||
|
$this->assertSame(0, $r2['read']);
|
||||||
|
$this->assertSame(0, $r2['uploaded']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testChangeInLargeFileUploadsOnlyAffectedBlob(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$r1 = $repo->backupDirectory($this->src);
|
||||||
|
$fh = fopen($this->src . '/bin/random.bin', 'r+b');
|
||||||
|
fseek($fh, 5 * 1048576);
|
||||||
|
fwrite($fh, 'changed');
|
||||||
|
fclose($fh);
|
||||||
|
touch($this->src . '/bin/random.bin', time() + 10);
|
||||||
|
$r2 = $repo->backupDirectory($this->src, $r1['snapshot']);
|
||||||
|
$this->assertSame(1, $r2['read']);
|
||||||
|
$this->assertSame(1, $r2['newBlobs']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPartialRestore(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$r = $repo->backupDirectory($this->src);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
$this->assertSame(10, $repo->restore($r['snapshot'], $target, 'docs/d1/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWrongPassphrase(): void {
|
||||||
|
Repository::init($this->backend, 'pw');
|
||||||
|
$this->expectException(CryptoException::class);
|
||||||
|
Repository::open($this->backend, 'other');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInitRefusesExistingRepository(): void {
|
||||||
|
Repository::init($this->backend, 'pw');
|
||||||
|
$this->expectException(RepositoryException::class);
|
||||||
|
Repository::init($this->backend, 'pw');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTamperedPackIsDetected(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$r = $repo->backupDirectory($this->src);
|
||||||
|
$root = (new \ReflectionProperty(LocalBackend::class, 'root'))->getValue($this->backend);
|
||||||
|
$pack = $root . '/' . $this->backend->list('packs')[0];
|
||||||
|
$bytes = file_get_contents($pack);
|
||||||
|
$bytes[200] = chr(ord($bytes[200]) ^ 0xff);
|
||||||
|
file_put_contents($pack, $bytes);
|
||||||
|
$this->expectException(\RuntimeException::class);
|
||||||
|
$repo->restore($r['snapshot'], $this->tempDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBlobWriterAndReadLinesAcrossBlobBoundaries(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$stats = [];
|
||||||
|
$writer = $repo->blobWriter($stats);
|
||||||
|
$lines = [];
|
||||||
|
for ($i = 0; $i < 20000; $i++) {
|
||||||
|
$lines[] = json_encode(['i' => $i, 'pad' => str_repeat('x', $i % 400)]);
|
||||||
|
}
|
||||||
|
$writer->write(implode("\n", $lines) . "\n");
|
||||||
|
$blobs = $writer->finish();
|
||||||
|
$repo->flushPacks();
|
||||||
|
$this->assertGreaterThan(1, count($blobs));
|
||||||
|
$this->assertSame($lines, iterator_to_array(Repository::open($this->backend, 'pw')->readLines($blobs), false));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testObjectsAreBoundToTheirPath(): void {
|
||||||
|
$repo = Repository::init($this->backend, 'pw');
|
||||||
|
$repo->putObject('db/1', 'one');
|
||||||
|
$root = (new \ReflectionProperty(LocalBackend::class, 'root'))->getValue($this->backend);
|
||||||
|
copy("$root/db/1", "$root/db/2");
|
||||||
|
$this->expectException(CryptoException::class);
|
||||||
|
$repo->getObject('db/2');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit;
|
||||||
|
|
||||||
|
trait TempDirTrait {
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $tempDirs = [];
|
||||||
|
|
||||||
|
protected function tempDir(): string {
|
||||||
|
$dir = sys_get_temp_dir() . '/ngb-test-' . bin2hex(random_bytes(6));
|
||||||
|
mkdir($dir, 0700, true);
|
||||||
|
$this->tempDirs[] = $dir;
|
||||||
|
return $dir;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void {
|
||||||
|
foreach ($this->tempDirs as $dir) {
|
||||||
|
exec('rm -rf ' . escapeshellarg($dir));
|
||||||
|
}
|
||||||
|
$this->tempDirs = [];
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, string> relative path => sha256 */
|
||||||
|
protected static function hashTree(string $root): array {
|
||||||
|
$out = [];
|
||||||
|
if (!is_dir($root)) {
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($root, \FilesystemIterator::SKIP_DOTS));
|
||||||
|
foreach ($it as $f) {
|
||||||
|
if ($f->isFile()) {
|
||||||
|
$out[substr($f->getPathname(), strlen($root) + 1)] = hash_file('sha256', $f->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ksort($out);
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require __DIR__ . '/../vendor/autoload.php';
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="../vendor/phpunit/phpunit/phpunit.xsd"
|
||||||
|
bootstrap="bootstrap.php" colors="true" failOnWarning="true" failOnRisky="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="unit">
|
||||||
|
<directory>Unit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
Reference in New Issue
Block a user