From 3f6785bcc0d17e6df46821549d78fb6c585a1b2c Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Fri, 2 Oct 2026 00:53:50 +0200 Subject: [PATCH] M2: full backup runs (database, config, data) with occ and cron BackupService starts a run per location (database dump in one transaction, then config + data directory as a resumable file run), stores the state in ngb_runs after every step, locks a run while a step executes and records the snapshot. Previews, logs, updater backups and per-user cache/uploads are excluded (TreeWalker now supports patterns). BackupJob (every 5 min) starts the daily schedule and steps running runs with a short budget under web cron and a long one under system cron. occ: backup:run, backup:status, backup:list. Tested on NC34/PostgreSQL: first run 128 files / 1 GiB in 64 s (443 MiB uploaded to S3), unchanged second run 2 s / 0 bytes; a background run to SFTP completed through cron.php. --- appinfo/info.xml | 8 +- lib/BackgroundJob/BackupJob.php | 72 +++++++++++ lib/Command/ListSnapshots.php | 49 ++++++++ lib/Command/RunBackup.php | 85 +++++++++++++ lib/Command/Status.php | 56 +++++++++ lib/Db/Run.php | 60 ++++++++++ lib/Db/RunMapper.php | 53 +++++++++ lib/Db/Snapshot.php | 43 +++++++ lib/Db/SnapshotMapper.php | 45 +++++++ lib/Job/TreeWalker.php | 31 +++-- lib/Service/BackupService.php | 204 ++++++++++++++++++++++++++++++++ tests/.phpunit.result.cache | 2 +- 12 files changed, 693 insertions(+), 15 deletions(-) create mode 100644 lib/BackgroundJob/BackupJob.php create mode 100644 lib/Command/ListSnapshots.php create mode 100644 lib/Command/RunBackup.php create mode 100644 lib/Command/Status.php create mode 100644 lib/Db/Run.php create mode 100644 lib/Db/RunMapper.php create mode 100644 lib/Db/Snapshot.php create mode 100644 lib/Db/SnapshotMapper.php create mode 100644 lib/Service/BackupService.php diff --git a/appinfo/info.xml b/appinfo/info.xml index e64dad3..5a6f1ee 100644 --- a/appinfo/info.xml +++ b/appinfo/info.xml @@ -5,7 +5,7 @@ NG Backup Encrypted, incremental backups of your Nextcloud with restore from the web interface - 0.0.1 + 0.0.2 agpl Dillard Blom NgBackup @@ -16,6 +16,9 @@ sodium + + OCA\NgBackup\BackgroundJob\BackupJob + OCA\NgBackup\Command\KeyInit OCA\NgBackup\Command\KeyKit @@ -24,5 +27,8 @@ OCA\NgBackup\Command\TargetAdd OCA\NgBackup\Command\TargetList OCA\NgBackup\Command\TargetRemove + OCA\NgBackup\Command\RunBackup + OCA\NgBackup\Command\Status + OCA\NgBackup\Command\ListSnapshots diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php new file mode 100644 index 0000000..6101a44 --- /dev/null +++ b/lib/BackgroundJob/BackupJob.php @@ -0,0 +1,72 @@ +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()); + } + } + } +} diff --git a/lib/Command/ListSnapshots.php b/lib/Command/ListSnapshots.php new file mode 100644 index 0000000..b21993d --- /dev/null +++ b/lib/Command/ListSnapshots.php @@ -0,0 +1,49 @@ +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; + } +} diff --git a/lib/Command/RunBackup.php b/lib/Command/RunBackup.php new file mode 100644 index 0000000..570e885 --- /dev/null +++ b/lib/Command/RunBackup.php @@ -0,0 +1,85 @@ +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('No such location.'); + return 1; + } + if ($targets === []) { + $output->writeln('No backup locations yet (occ backup:target:add).'); + return 1; + } + $failed = 0; + foreach ($targets as $t) { + try { + $run = $this->backups->start($t, (string)$input->getOption('label')); + } catch (\Throwable $e) { + $output->writeln('' . $t->getName() . ': ' . $e->getMessage() . ''); + $failed++; + continue; + } + $output->writeln(sprintf('%s: 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(' ' . $run->getStatus() . ': ' . $run->getError() . ''); + $failed++; + } + } + return $failed === 0 ? 0 : 1; + } +} diff --git a/lib/Command/Status.php b/lib/Command/Status.php new file mode 100644 index 0000000..b028519 --- /dev/null +++ b/lib/Command/Status.php @@ -0,0 +1,56 @@ +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: not created (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']) : 'not confirmed (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; + } +} diff --git a/lib/Db/Run.php b/lib/Db/Run.php new file mode 100644 index 0000000..b881d53 --- /dev/null +++ b/lib/Db/Run.php @@ -0,0 +1,60 @@ +addType('targetId', 'integer'); + $this->addType('startedAt', 'integer'); + $this->addType('updatedAt', 'integer'); + $this->addType('finishedAt', 'integer'); + } +} diff --git a/lib/Db/RunMapper.php b/lib/Db/RunMapper.php new file mode 100644 index 0000000..6fe4d12 --- /dev/null +++ b/lib/Db/RunMapper.php @@ -0,0 +1,53 @@ + */ +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 */ + 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 */ + 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; + } +} diff --git a/lib/Db/Snapshot.php b/lib/Db/Snapshot.php new file mode 100644 index 0000000..131f582 --- /dev/null +++ b/lib/Db/Snapshot.php @@ -0,0 +1,43 @@ +addType('targetId', 'integer'); + $this->addType('createdAt', 'integer'); + $this->addType('files', 'integer'); + $this->addType('bytes', 'integer'); + } +} diff --git a/lib/Db/SnapshotMapper.php b/lib/Db/SnapshotMapper.php new file mode 100644 index 0000000..17847f9 --- /dev/null +++ b/lib/Db/SnapshotMapper.php @@ -0,0 +1,45 @@ + */ +class SnapshotMapper extends QBMapper { + public function __construct(IDBConnection $db) { + parent::__construct($db, 'ngb_snapshots', Snapshot::class); + } + + /** @return list 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; + } +} diff --git a/lib/Job/TreeWalker.php b/lib/Job/TreeWalker.php index 67ad91a..a685058 100644 --- a/lib/Job/TreeWalker.php +++ b/lib/Job/TreeWalker.php @@ -17,7 +17,8 @@ namespace OCA\NgBackup\Job; final class TreeWalker { /** * @param array $roots name => absolute directory, walked in this order - * @param list $exclude path prefixes ("/") that are skipped entirely + * @param list $exclude path prefixes ("/") that are skipped entirely; + * a pattern with "*" matches one path segment (fnmatch, FNM_PATHNAME) */ public function __construct( private array $roots, @@ -51,10 +52,8 @@ final class TreeWalker { } private function walkDir(string $abs, string $logical, ?string $after): \Generator { - foreach ($this->exclude as $prefix) { - if ($logical !== '' && ($logical === $prefix || str_starts_with($logical, $prefix . '/'))) { - return; - } + if ($logical !== '' && $this->isExcluded($logical, true)) { + return; } $names = @scandir($abs); if ($names === false) { @@ -78,20 +77,26 @@ final class TreeWalker { if ($after !== null && self::compare($childLogical, $after) <= 0) { continue; } - $excluded = false; - foreach ($this->exclude as $prefix) { - if ($childLogical === $prefix) { - $excluded = true; - break; - } - } - if (!$excluded) { + 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 . '/'); } diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php new file mode 100644 index 0000000..2024d52 --- /dev/null +++ b/lib/Service/BackupService.php @@ -0,0 +1,204 @@ +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 */ + 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 app id => version */ + private function installedApps(): array { + $apps = []; + foreach ($this->appManager->getEnabledApps() as $app) { + $apps[$app] = $this->appManager->getAppVersion($app); + } + ksort($apps); + return $apps; + } +} diff --git a/tests/.phpunit.result.cache b/tests/.phpunit.result.cache index 34c318e..e9fa417 100644 --- a/tests/.phpunit.result.cache +++ b/tests/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":{"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":7},"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.075,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.094,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.004,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.276,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0,"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.498,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":1.8,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":2.005,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.016,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.136,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.049,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.087,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.064,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.095,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.24,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.544,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":2.802,"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.005}} \ No newline at end of file +{"version":2,"defects":{"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":7},"times":{"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrapUnwrapKeepsKeys":1.068,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testWrongPassphrase":1.175,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testBlobIdsAreKeyed":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\KeyRingTest::testRejectsShortMasterKey":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#empty":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#small":0,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#one frame":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#frame+1":0.001,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testRoundTrip#several":0.003,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testStreamingKeepsMemoryBounded":0.234,"OCA\\NgBackup\\Tests\\Unit\\Crypto\\StreamCipherTest::testCorruptionIsRejected#flipped byte":0,"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.39,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testFileChangedBetweenStepsIsReadAgain":1.724,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBackupAndRestoreAreIdentical":1.985,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testUnchangedRunReadsAndUploadsNothing":1.045,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testChangeInLargeFileUploadsOnlyAffectedBlob":1.175,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testPartialRestore":1.101,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testWrongPassphrase":1.038,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testInitRefusesExistingRepository":1.024,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testTamperedPackIsDetected":1.06,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testBlobWriterAndReadLinesAcrossBlobBoundaries":1.378,"OCA\\NgBackup\\Tests\\Unit\\Repository\\RepositoryTest::testObjectsAreBoundToTheirPath":0.544,"OCA\\NgBackup\\Tests\\Unit\\Job\\BackupRunTest::testIncrementalRunInTinyStepsReadsNothing":2.984,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testOrderMatchesCompare":0.003,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testResumeAfterCursorYieldsTheRest":0.003,"OCA\\NgBackup\\Tests\\Unit\\Job\\TreeWalkerTest::testExcludeAndMultipleRoots":0.069}} \ No newline at end of file