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