Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa7cbf6ef | |||
| f4abfe0a8d | |||
| fe1a289c2d | |||
| ab3265e42c | |||
| 466cff8857 | |||
| 6bba0b59fc | |||
| 62766e1e71 | |||
| bfd54447b0 | |||
| 0b50bb6b81 | |||
| f415bab519 | |||
| 27eb0b32fc | |||
| b1d4f6a469 | |||
| fe7ad9b4b5 | |||
| 695d972785 | |||
| 972a52e665 | |||
| 7c08a10621 |
@@ -7,6 +7,38 @@ the app store accepts a `-beta` suffix, but Nextcloud servers then only offer th
|
||||
beta/daily update channel, which would hide the app from most admins. The stability stage is
|
||||
instead carried by the app description, the git tag, the release, and this file.
|
||||
|
||||
## [0.9.3-beta.4] - 2026-10-08
|
||||
|
||||
Bugfix release.
|
||||
|
||||
### Fixed
|
||||
- A backup failed with "cannot execute INSERT in a read-only transaction" on PostgreSQL as soon
|
||||
as the database dump filled a whole pack (32 MiB): the pack's index cache row was written
|
||||
inside the read-only dump transaction. On other databases that row was rolled back silently.
|
||||
- Backups to a remote location slowed down as the repository grew: every backup step listed the
|
||||
repository's index with a round trip per file. Over SFTP to a Hetzner Storage Box a 60 GiB
|
||||
repository already lost a large part of each step to it; backing up continuously
|
||||
(`occ backup:run`) went from 45 KiB/s to 5.8 MB/s with the fix.
|
||||
|
||||
## [0.9.2-beta.3] - 2026-10-08
|
||||
|
||||
Bugfix release.
|
||||
|
||||
### Added
|
||||
- README: notes on using a Hetzner Storage Box over WebDAV and SMB.
|
||||
|
||||
### Fixed
|
||||
- Adding a backup location in the web interface failed with "Request failed with status code 403"
|
||||
when the login was more than 30 minutes old: the form did not ask for password confirmation.
|
||||
- Settings page: the "Backup and restore" / "Settings" tabs line up with the sections below them.
|
||||
- Backups: the runs table scrolls horizontally on narrow screens instead of running off the page.
|
||||
- `occ backup:verify --deep`: a blob that could not be read (network or storage error) was reported
|
||||
as "corrupted or tampered". Reads are now retried, and a read that keeps failing is reported
|
||||
separately as unreadable, with the error (run the check again; if the same blobs stay unreadable,
|
||||
their pack may be damaged or truncated). Only a blob that was read but fails decryption or its
|
||||
hash check counts as corrupted. Found on a Hetzner Storage Box over WebDAV, where the blob
|
||||
turned out to be intact.
|
||||
|
||||
## [0.9.1-beta.2] - 2026-10-07
|
||||
|
||||
Second beta, the first release in the Nextcloud app store (signed).
|
||||
@@ -103,6 +135,8 @@ and not implemented yet.
|
||||
- See the "Storage backend experiences" section of the README for provider-specific quirks found
|
||||
so far (e.g. Hetzner Storage Box sub-accounts).
|
||||
|
||||
[0.9.3-beta.4]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.3-beta.4
|
||||
[0.9.2-beta.3]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.2-beta.3
|
||||
[0.9.1-beta.2]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.1-beta.2
|
||||
[0.9.0-beta.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.0-beta.1
|
||||
[0.1.0-alpha.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.1.0-alpha.1
|
||||
|
||||
@@ -7,7 +7,7 @@ installations and hosted Nextcloud. (Choosing SMB as a storage backend is the on
|
||||
see "Storage backend experiences" below — that requirement comes from files_external, not from
|
||||
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
||||
|
||||
**Status: beta (0.9.1).** Tested thoroughly, usable alongside your current backup, but not yet a
|
||||
**Status: beta (0.9.3).** Tested thoroughly, usable alongside your current backup, but not yet a
|
||||
drop-in replacement for it. See "Known limitations" below, `PLAN.md` for the design and
|
||||
`docs/HOWTO.md` for a step-by-step guide from setup to a tested restore.
|
||||
|
||||
@@ -126,6 +126,20 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
||||
verified whether this also applies to a Hetzner Storage Box **main** account (only tested with a
|
||||
sub-account so far) — if you've tried a main account, please say so in a PR.
|
||||
|
||||
- **Hetzner Storage Box over WebDAV and SMB (sub-accounts, 2026-10-07):** both work end to end
|
||||
(backup, incremental run, restore of a folder, verify), tested from outside the Hetzner network
|
||||
with about 66 MiB of data. In the Hetzner Console, enable WebDAV or SMB support for the
|
||||
sub-account, and "External reachability" when your server is not in the Hetzner network.
|
||||
WebDAV: host `https://uXXXXX-subN.your-storagebox.de`, and the `root`
|
||||
must be a folder that already exists (or empty): WebDAV does not create missing parent folders,
|
||||
so a root that doesn't exist yet fails `backup:target:add` with `Sabre\HTTP\ClientHttpException:
|
||||
Conflict`. SMB: share = the sub-account name, port 445 must be reachable from your server (it was
|
||||
from a home connection here, but some providers block it), and the `smbclient` PECL extension is
|
||||
required (see the SMB entry below). Both have a noticeable fixed cost per run: an incremental run
|
||||
that uploads nothing took about 20 s over WebDAV and 50 s over SMB. A deep verify is slow over
|
||||
remote storage, since each blob is fetched with its own request (19 minutes for 66 MiB over
|
||||
WebDAV); run it occasionally, not after every backup.
|
||||
|
||||
- **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds
|
||||
absolute paths (e.g. `MKD /repo`) for the configured root, never relative ones. `atmoz/sftp`-style
|
||||
`vsftpd` test servers (chrooted) can reject that with a generic "create directory operation
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files a
|
||||
single users (via user_migration) and of the whole server onto a fresh installation. Tested
|
||||
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
|
||||
See the README for known limitations and the recovery kit before relying on it.]]></description>
|
||||
<version>0.9.1</version>
|
||||
<version>0.9.3</version>
|
||||
<licence>agpl</licence>
|
||||
<author>Dillard Blom</author>
|
||||
<namespace>NgBackup</namespace>
|
||||
|
||||
@@ -37,5 +37,15 @@ interface IBackend {
|
||||
/** @return list<string> object paths below $prefix (recursive) */
|
||||
public function list(string $prefix): array;
|
||||
|
||||
/**
|
||||
* Object paths directly in $dir, for a folder that only holds objects (index/, snapshots/,
|
||||
* catalog/, trash/info/). One listing, without checking the type of every entry: on a remote
|
||||
* storage list() costs a round trip per entry, which for index/ (one file per pack) grows
|
||||
* with the size of the repository.
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listFiles(string $dir): array;
|
||||
|
||||
public function delete(string $path): void;
|
||||
}
|
||||
|
||||
@@ -107,6 +107,25 @@ final class LocalBackend implements IBackend {
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function listFiles(string $dir): array {
|
||||
$base = $this->abs($dir);
|
||||
if (!is_dir($base)) {
|
||||
return [];
|
||||
}
|
||||
$result = [];
|
||||
$names = scandir($base);
|
||||
if ($names === false) {
|
||||
throw new BackendException('Cannot list ' . $dir);
|
||||
}
|
||||
foreach ($names as $name) {
|
||||
if ($name !== '.' && $name !== '..' && !StorageBackend::isTemporary($name)) {
|
||||
$result[] = trim($dir, '/') . '/' . $name;
|
||||
}
|
||||
}
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function delete(string $path): void {
|
||||
@unlink($this->abs($path));
|
||||
}
|
||||
|
||||
@@ -133,6 +133,28 @@ final class StorageBackend implements IBackend {
|
||||
}
|
||||
}
|
||||
|
||||
public function listFiles(string $dir): array {
|
||||
$abs = $this->abs($dir);
|
||||
if (!$this->storage->is_dir($abs)) {
|
||||
return []; // not created yet
|
||||
}
|
||||
$dh = $this->storage->opendir($abs);
|
||||
if ($dh === false) {
|
||||
// An existing folder read as empty would look like a new repository.
|
||||
throw new BackendException('Cannot list ' . $dir);
|
||||
}
|
||||
$cut = $this->base === '' ? 0 : strlen($this->base) + 1;
|
||||
$result = [];
|
||||
while (($name = readdir($dh)) !== false) {
|
||||
if ($name !== '.' && $name !== '..' && !self::isTemporary($name)) {
|
||||
$result[] = substr($abs . '/' . $name, $cut);
|
||||
}
|
||||
}
|
||||
closedir($dh);
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
private function listInto(string $dir, array &$result): void {
|
||||
if (!$this->storage->is_dir($dir)) {
|
||||
return;
|
||||
|
||||
@@ -66,7 +66,13 @@ HELP);
|
||||
if ($result['failed'] !== []) {
|
||||
$output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $result['failed']));
|
||||
}
|
||||
if ($result['missing'] === [] && $result['failed'] === []) {
|
||||
if ($result['unreadable'] !== []) {
|
||||
$output->writeln('<error>' . count($result['unreadable']) . ' blob(s) could not be read from the location</error> (often a temporary storage '
|
||||
. 'or network error: run the check again; if the same blobs stay unreadable, their pack may be damaged or truncated): '
|
||||
. implode(', ', $result['unreadable']));
|
||||
$output->writeln('Last read error: ' . $result['readError']);
|
||||
}
|
||||
if ($result['missing'] === [] && $result['failed'] === [] && $result['unreadable'] === []) {
|
||||
$output->writeln('<info>OK</info>');
|
||||
return 0;
|
||||
}
|
||||
|
||||
+43
-36
@@ -72,50 +72,57 @@ final class DbDumper {
|
||||
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
||||
$state['steps']++;
|
||||
$sm = $this->db->createSchemaManager();
|
||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||
$this->db->beginTransaction();
|
||||
// A pack that fills up during the dump is uploaded right away; its index cache row must
|
||||
// wait until the read-only transaction is over.
|
||||
$repo->holdIndexCache();
|
||||
try {
|
||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||
}
|
||||
while ($state['i'] < count($state['tables'])) {
|
||||
$table = $state['tables'][$state['i']];
|
||||
$schema = $sm->introspectTable($table);
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
||||
if ($state['after'] === null && $state['segments'] === []) {
|
||||
$writer->write($this->header($schema) . "\n");
|
||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||
$this->db->beginTransaction();
|
||||
try {
|
||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||
}
|
||||
$complete = true;
|
||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
||||
$writer->write($line . "\n");
|
||||
hash_update($hash, $line . "\n");
|
||||
$state['rows']++;
|
||||
$state['after'] = $key;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$complete = false;
|
||||
while ($state['i'] < count($state['tables'])) {
|
||||
$table = $state['tables'][$state['i']];
|
||||
$schema = $sm->introspectTable($table);
|
||||
$writer = $repo->blobWriter($stats);
|
||||
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
||||
if ($state['after'] === null && $state['segments'] === []) {
|
||||
$writer->write($this->header($schema) . "\n");
|
||||
}
|
||||
$complete = true;
|
||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
||||
$writer->write($line . "\n");
|
||||
hash_update($hash, $line . "\n");
|
||||
$state['rows']++;
|
||||
$state['after'] = $key;
|
||||
if (microtime(true) >= $deadline) {
|
||||
$complete = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state['segments'][] = $writer->finish();
|
||||
$state['bytes'] += $writer->bytes();
|
||||
if (!$complete) {
|
||||
$state['hash'] = base64_encode(serialize($hash));
|
||||
break;
|
||||
}
|
||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
||||
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
||||
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state['segments'][] = $writer->finish();
|
||||
$state['bytes'] += $writer->bytes();
|
||||
if (!$complete) {
|
||||
$state['hash'] = base64_encode(serialize($hash));
|
||||
break;
|
||||
if ($state['i'] >= count($state['tables'])) {
|
||||
$state['sequences'] = $this->sequenceStates($state['tables']);
|
||||
$state['done'] = true;
|
||||
}
|
||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
||||
$state = array_merge($state, ['i' => $state['i'] + 1, 'after' => null, 'segments' => [], 'rows' => 0, 'bytes' => 0, 'hash' => null]);
|
||||
if (microtime(true) >= $deadline && $state['i'] < count($state['tables'])) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($state['i'] >= count($state['tables'])) {
|
||||
$state['sequences'] = $this->sequenceStates($state['tables']);
|
||||
$state['done'] = true;
|
||||
} finally {
|
||||
$this->db->rollBack();
|
||||
}
|
||||
} finally {
|
||||
$this->db->rollBack();
|
||||
$repo->releaseIndexCache();
|
||||
}
|
||||
$repo->flushPacks();
|
||||
return $state;
|
||||
|
||||
@@ -25,6 +25,8 @@ final class BlobIndex {
|
||||
private array $pending = [];
|
||||
|
||||
private ?IIndexCache $cache = null;
|
||||
/** @var list<\Closure():void>|null cache writes held back by holdCache(), or null when writing directly */
|
||||
private ?array $heldCacheWrites = null;
|
||||
private string $repositoryId = '';
|
||||
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
||||
public int $downloaded = 0;
|
||||
@@ -40,6 +42,46 @@ final class BlobIndex {
|
||||
$this->repositoryId = $repositoryId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hold back cache writes until releaseCache(). The database dump reads inside a read-only
|
||||
* transaction on the same connection that the cache writes to: a write there fails on
|
||||
* PostgreSQL and would be rolled back with the dump transaction elsewhere.
|
||||
*/
|
||||
public function holdCache(): void {
|
||||
$this->heldCacheWrites ??= [];
|
||||
}
|
||||
|
||||
/** Write the cache changes held back since holdCache(). */
|
||||
public function releaseCache(): void {
|
||||
$writes = $this->heldCacheWrites ?? [];
|
||||
$this->heldCacheWrites = null;
|
||||
foreach ($writes as $write) {
|
||||
$write();
|
||||
}
|
||||
}
|
||||
|
||||
/** @param list<array{id:string, offset:int, length:int, raw:int, flags:int}> $entries */
|
||||
private function cachePut(string $packId, array $entries): void {
|
||||
if ($this->heldCacheWrites !== null) {
|
||||
$this->heldCacheWrites[] = function () use ($packId, $entries): void {
|
||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||
};
|
||||
return;
|
||||
}
|
||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||
}
|
||||
|
||||
/** @param list<string> $packIds */
|
||||
private function cacheRemove(array $packIds): void {
|
||||
if ($this->heldCacheWrites !== null) {
|
||||
$this->heldCacheWrites[] = function () use ($packIds): void {
|
||||
$this->cache?->remove($this->repositoryId, $packIds);
|
||||
};
|
||||
return;
|
||||
}
|
||||
$this->cache?->remove($this->repositoryId, $packIds);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -50,7 +92,7 @@ final class BlobIndex {
|
||||
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
||||
$present = [];
|
||||
$this->downloaded = 0;
|
||||
foreach ($this->backend->list('index') as $path) {
|
||||
foreach ($this->backend->listFiles('index') as $path) {
|
||||
$packId = basename($path);
|
||||
$present[$packId] = true;
|
||||
if (isset($cached[$packId])) {
|
||||
@@ -112,7 +154,7 @@ final class BlobIndex {
|
||||
fwrite($stream, $this->cipher->encryptString(json_encode($entries, JSON_THROW_ON_ERROR), 'index:' . $packId));
|
||||
rewind($stream);
|
||||
$this->backend->put('index/' . $packId, $stream);
|
||||
$this->cache?->put($this->repositoryId, $packId, $entries);
|
||||
$this->cachePut($packId, $entries);
|
||||
foreach ($entries as $e) {
|
||||
$this->blobs[$e['id']] = [$packId, $e['offset'], $e['length'], $e['raw'], $e['flags']];
|
||||
unset($this->pending[$e['id']]);
|
||||
@@ -131,7 +173,7 @@ final class BlobIndex {
|
||||
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
||||
public function removePack(string $packId): void {
|
||||
$this->backend->delete('index/' . $packId);
|
||||
$this->cache?->remove($this->repositoryId, [$packId]);
|
||||
$this->cacheRemove([$packId]);
|
||||
foreach ($this->blobs as $id => $loc) {
|
||||
if ($loc[0] === $packId) {
|
||||
unset($this->blobs[$id]);
|
||||
|
||||
@@ -128,7 +128,7 @@ final class Catalog {
|
||||
|
||||
/** @return list<int> */
|
||||
private function generations(): array {
|
||||
return array_map(fn ($p) => (int)basename($p), $this->backend->list('catalog'));
|
||||
return array_map(fn ($p) => (int)basename($p), $this->backend->listFiles('catalog'));
|
||||
}
|
||||
|
||||
private function path(int $gen): string {
|
||||
|
||||
@@ -28,6 +28,8 @@ final class Repository {
|
||||
public const FORMAT = 1;
|
||||
public const BLOB_SIZE = 4 * 1048576;
|
||||
private const FLAG_DEFLATE = 1;
|
||||
/** Reads per blob in a deep verify before it is reported as unreadable (not corrupted). */
|
||||
private const VERIFY_READ_ATTEMPTS = 3;
|
||||
|
||||
private StreamCipher $cipher;
|
||||
private BlobIndex $index;
|
||||
@@ -90,8 +92,8 @@ final class Repository {
|
||||
private function loadCatalog(?ICatalogAnchor $anchor): void {
|
||||
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
|
||||
$this->catalog->load(
|
||||
fn () => array_map('basename', $this->backend->list('snapshots')),
|
||||
fn () => array_map('basename', $this->backend->list('trash/info')),
|
||||
fn () => array_map('basename', $this->backend->listFiles('snapshots')),
|
||||
fn () => array_map('basename', $this->backend->listFiles('trash/info')),
|
||||
fn () => $this->backend->list('users'));
|
||||
}
|
||||
|
||||
@@ -260,6 +262,15 @@ final class Repository {
|
||||
});
|
||||
}
|
||||
|
||||
/** See BlobIndex::holdCache(): no database writes until releaseIndexCache(). */
|
||||
public function holdIndexCache(): void {
|
||||
$this->index->holdCache();
|
||||
}
|
||||
|
||||
public function releaseIndexCache(): void {
|
||||
$this->index->releaseCache();
|
||||
}
|
||||
|
||||
public function flushPacks(): void {
|
||||
$this->runPacks?->flush();
|
||||
}
|
||||
@@ -632,7 +643,7 @@ final class Repository {
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listSnapshots(): array {
|
||||
$present = array_flip(array_map('basename', $this->backend->list('snapshots')));
|
||||
$present = array_flip(array_map('basename', $this->backend->listFiles('snapshots')));
|
||||
$listed = array_keys($this->catalog->snapshots);
|
||||
foreach ($listed as $id) {
|
||||
if (!isset($present[$id])) {
|
||||
@@ -696,8 +707,13 @@ final class Repository {
|
||||
}
|
||||
|
||||
private function loadBlob(string $id): string {
|
||||
[$pack, $offset, $length, $raw, $flags] = $this->index->get($id);
|
||||
$encrypted = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
||||
[$pack, $offset, $length] = $this->index->get($id);
|
||||
return $this->decodeBlob($id, $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length));
|
||||
}
|
||||
|
||||
/** Decrypts and checks a blob read from its pack; throws when it is corrupted or tampered with. */
|
||||
private function decodeBlob(string $id, string $encrypted): string {
|
||||
[, , , $raw, $flags] = $this->index->get($id);
|
||||
$payload = $this->cipher->decryptString($encrypted, 'blob:' . $id);
|
||||
$data = ($flags & self::FLAG_DEFLATE) ? gzinflate($payload) : $payload;
|
||||
if ($data === false || strlen($data) !== $raw || $this->keys->blobId($data) !== $id) {
|
||||
@@ -733,13 +749,13 @@ final class Repository {
|
||||
*
|
||||
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
|
||||
* callers use it to keep a time-boxed lease alive during a large audit
|
||||
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>}
|
||||
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
|
||||
*/
|
||||
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
|
||||
$heartbeat ??= static function (): void {
|
||||
};
|
||||
$this->index->load(true);
|
||||
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []];
|
||||
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
|
||||
$seen = [];
|
||||
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
|
||||
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||
@@ -778,7 +794,7 @@ final class Repository {
|
||||
* Same checks as verify(), for a user export: every blob its manifest references must be in
|
||||
* the freshly re-authenticated index with its pack present; $deep also decrypts each.
|
||||
*
|
||||
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>}
|
||||
* @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string}
|
||||
*/
|
||||
public function verifyUserExport(string $manifestPath, bool $deep = false, ?callable $heartbeat = null): array {
|
||||
if (!$this->isRecordedUserExport($manifestPath)) {
|
||||
@@ -787,7 +803,7 @@ final class Repository {
|
||||
$heartbeat ??= static function (): void {
|
||||
};
|
||||
$this->index->load(true);
|
||||
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => []];
|
||||
$stats = ['filesChecked' => 0, 'blobsChecked' => 0, 'bytesChecked' => 0, 'missing' => [], 'failed' => [], 'unreadable' => [], 'readError' => ''];
|
||||
$seen = [];
|
||||
$packSeen = [];
|
||||
$manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||
@@ -824,8 +840,25 @@ final class Repository {
|
||||
if (!$deep) {
|
||||
return;
|
||||
}
|
||||
// Reading and checking are kept apart: a read that fails is usually a network or
|
||||
// storage error, so it is retried and reported as unreadable (a pack that stays
|
||||
// unreadable may still be truncated). Only a blob that was read but does not
|
||||
// decrypt or match its id counts as failed: that is proof of damage.
|
||||
[, $offset, $length] = $this->index->get($id);
|
||||
$encrypted = null;
|
||||
for ($attempt = 1; $attempt <= self::VERIFY_READ_ATTEMPTS && $encrypted === null; $attempt++) {
|
||||
try {
|
||||
$encrypted = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
||||
} catch (\Exception $e) { // storage adapters throw their own exceptions; \Error (a bug) is not retried
|
||||
$stats['readError'] = $e->getMessage();
|
||||
}
|
||||
}
|
||||
if ($encrypted === null) {
|
||||
$stats['unreadable'][] = $id;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
$stats['bytesChecked'] += strlen($this->loadBlob($id));
|
||||
$stats['bytesChecked'] += strlen($this->decodeBlob($id, $encrypted));
|
||||
} catch (\Throwable) {
|
||||
$stats['failed'][] = $id;
|
||||
}
|
||||
|
||||
@@ -18,12 +18,12 @@ final class VerifyService {
|
||||
) {
|
||||
}
|
||||
|
||||
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} */
|
||||
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
|
||||
public function verify(Target $target, string $snapshotId, bool $deep = false): array {
|
||||
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verify($snapshotId, $deep, $heartbeat));
|
||||
}
|
||||
|
||||
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>} */
|
||||
/** @return array{filesChecked:int, blobsChecked:int, bytesChecked:int, missing:list<string>, failed:list<string>, unreadable:list<string>, readError:string} */
|
||||
public function verifyUserExport(Target $target, string $manifestPath, bool $deep = false): array {
|
||||
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ onBeforeUnmount(() => clearTimeout(timer))
|
||||
.ngb-tabs {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 0 0 12px;
|
||||
/* Same inline margin as NcSettingsSection, so the tabs line up with the section content */
|
||||
margin: 0 calc(var(--default-grid-baseline) * 7) 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
+27
-24
@@ -30,30 +30,32 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<table v-if="status.runs.length" class="ngb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||
<th>{{ t('ng_backup', 'Files') }}</th>
|
||||
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
||||
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
||||
<td>{{ targetName(run.targetId) }}</td>
|
||||
<td>{{ run.kind }}</td>
|
||||
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-if="status.runs.length" class="ngb-table-wrap">
|
||||
<table class="ngb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||
<th>{{ t('ng_backup', 'Files') }}</th>
|
||||
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
||||
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
||||
<td>{{ targetName(run.targetId) }}</td>
|
||||
<td>{{ run.kind }}</td>
|
||||
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</NcSettingsSection>
|
||||
</template>
|
||||
|
||||
@@ -126,6 +128,7 @@ async function saveSchedule() {
|
||||
<style scoped>
|
||||
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.ngb-time { max-width: 260px; }
|
||||
.ngb-table-wrap { overflow-x: auto; }
|
||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
||||
.ngb-table th, .ngb-table td { text-align: start; padding: 4px 8px; border-bottom: 1px solid var(--color-border); }
|
||||
.ngb-failed { color: var(--color-error-text); }
|
||||
|
||||
@@ -104,6 +104,7 @@ async function add() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
await confirmPassword()
|
||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
|
||||
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
|
||||
adding.value = false
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||
|
||||
use OCA\NgBackup\Backend\BackendException;
|
||||
use OCA\NgBackup\Backend\StorageBackend;
|
||||
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||
use OCP\Files\Storage\IStorage;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/** On a remote storage every is_dir() is a round trip: listing index/ must not cost one per pack. */
|
||||
class StorageBackendListTest extends TestCase {
|
||||
use TempDirTrait;
|
||||
|
||||
public function testListFilesChecksOnlyTheFolderItself(): void {
|
||||
$dir = $this->tempDir();
|
||||
foreach (['b1', 'a2', 'c3.part-0123abcd'] as $name) {
|
||||
touch("$dir/$name");
|
||||
}
|
||||
$isDirCalls = 0;
|
||||
$storage = $this->createMock(IStorage::class);
|
||||
$storage->method('instanceOfStorage')->willReturn(false);
|
||||
$storage->method('is_dir')->willReturnCallback(function (string $path) use (&$isDirCalls): bool {
|
||||
$isDirCalls++;
|
||||
return $path === 'repo' || $path === 'repo/index';
|
||||
});
|
||||
$storage->method('opendir')->willReturnCallback(fn (string $path) => $path === 'repo/index' ? opendir($dir) : false);
|
||||
|
||||
$backend = new StorageBackend($storage, 'repo');
|
||||
$isDirCalls = 0; // the constructor checks the base folder
|
||||
$this->assertSame(['index/a2', 'index/b1'], $backend->listFiles('index'));
|
||||
$this->assertSame(1, $isDirCalls);
|
||||
|
||||
$isDirCalls = 0;
|
||||
$this->assertSame(['index/a2', 'index/b1'], $backend->list('index'));
|
||||
$this->assertSame(4, $isDirCalls, 'list() checks every entry: the reason listFiles() exists');
|
||||
}
|
||||
|
||||
public function testListFilesOfAMissingFolderIsEmpty(): void {
|
||||
$storage = $this->createMock(IStorage::class);
|
||||
$storage->method('instanceOfStorage')->willReturn(false);
|
||||
$storage->method('is_dir')->willReturnCallback(fn (string $path): bool => $path === 'repo');
|
||||
$storage->expects($this->never())->method('opendir');
|
||||
$this->assertSame([], (new StorageBackend($storage, 'repo'))->listFiles('snapshots'));
|
||||
}
|
||||
|
||||
public function testAnUnreadableFolderIsAnErrorNotEmpty(): void {
|
||||
$storage = $this->createMock(IStorage::class);
|
||||
$storage->method('instanceOfStorage')->willReturn(false);
|
||||
$storage->method('is_dir')->willReturn(true);
|
||||
$storage->method('opendir')->willReturn(false);
|
||||
$this->expectException(BackendException::class);
|
||||
(new StorageBackend($storage, 'repo'))->listFiles('catalog');
|
||||
}
|
||||
}
|
||||
@@ -51,4 +51,41 @@ class IndexCacheTest extends TestCase {
|
||||
$fourth->restore($r1['snapshot'], $target);
|
||||
$this->assertCount(12, self::hashTree($target));
|
||||
}
|
||||
|
||||
/** The database dump reads in a read-only transaction: a pack that fills up then must not write the cache yet. */
|
||||
public function testHeldCacheWritesWaitForRelease(): void {
|
||||
$cache = new class implements IIndexCache {
|
||||
public array $data = [];
|
||||
public bool $readOnly = false;
|
||||
public function all(string $r): array { return $this->data[$r] ?? []; }
|
||||
public function put(string $r, string $p, array $e): void {
|
||||
if ($this->readOnly) {
|
||||
throw new \RuntimeException('cannot execute INSERT in a read-only transaction');
|
||||
}
|
||||
$this->data[$r][$p] = $e;
|
||||
}
|
||||
public function remove(string $r, array $ps): void { foreach ($ps as $p) { unset($this->data[$r][$p]); } }
|
||||
};
|
||||
$backend = new LocalBackend($this->tempDir());
|
||||
$keys = KeyRing::generate();
|
||||
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||
$repo = Repository::openWithKey($backend, $keys, $cache);
|
||||
|
||||
$stats = [];
|
||||
$repo->holdIndexCache();
|
||||
$cache->readOnly = true;
|
||||
$writer = $repo->blobWriter($stats);
|
||||
for ($i = 0; $i < 40; $i++) {
|
||||
$writer->write(random_bytes(1048576)); // more than one 32 MiB pack
|
||||
}
|
||||
$writer->finish();
|
||||
$this->assertGreaterThan(0, count($backend->list('index')), 'a full pack was uploaded during the hold');
|
||||
$this->assertSame([], $cache->data);
|
||||
|
||||
$cache->readOnly = false;
|
||||
$repo->releaseIndexCache();
|
||||
$repo->flushPacks();
|
||||
$this->assertCount(count($backend->list('index')), array_merge(...array_values($cache->data)));
|
||||
$this->assertSame(0, Repository::openWithKey($backend, $keys, $cache)->indexDownloads());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
||||
|
||||
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||
|
||||
use OCA\NgBackup\Backend\BackendException;
|
||||
use OCA\NgBackup\Backend\IBackend;
|
||||
use OCA\NgBackup\Backend\LocalBackend;
|
||||
use OCA\NgBackup\Crypto\KeyRing;
|
||||
use OCA\NgBackup\Repository\Repository;
|
||||
@@ -92,4 +94,83 @@ class VerifyTest extends TestCase {
|
||||
|
||||
$this->assertNotSame([], $result['failed']);
|
||||
}
|
||||
|
||||
public function testDeepVerifyRetriesAReadThatFailsOnce(): void {
|
||||
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||
$s = $repo->backupDirectory($this->src);
|
||||
$flaky = new FlakyRangeBackend($this->backend, failures: 1);
|
||||
|
||||
$result = Repository::openWithKey($flaky, $this->keys, null, null)->verify($s['snapshot'], deep: true);
|
||||
|
||||
$this->assertSame(200000, $result['bytesChecked']);
|
||||
$this->assertSame([], $result['failed']);
|
||||
$this->assertSame([], $result['unreadable']);
|
||||
}
|
||||
|
||||
public function testDeepVerifyReportsAPersistentReadErrorAsUnreadableNotCorrupted(): void {
|
||||
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||
$s = $repo->backupDirectory($this->src);
|
||||
$broken = new FlakyRangeBackend($this->backend, failures: PHP_INT_MAX);
|
||||
|
||||
$result = Repository::openWithKey($broken, $this->keys, null, null)->verify($s['snapshot'], deep: true);
|
||||
|
||||
$this->assertSame([], $result['failed']);
|
||||
$this->assertNotSame([], $result['unreadable']);
|
||||
$this->assertSame('connection reset', $result['readError']);
|
||||
}
|
||||
|
||||
public function testDeepVerifyDoesNotHideAProgrammingError(): void {
|
||||
$repo = Repository::initWithKey($this->backend, $this->keys, $this->keys->wrap('pw'));
|
||||
$s = $repo->backupDirectory($this->src);
|
||||
$buggy = new FlakyRangeBackend($this->backend, failures: PHP_INT_MAX, error: new \TypeError('bug'));
|
||||
|
||||
$this->expectException(\TypeError::class);
|
||||
Repository::openWithKey($buggy, $this->keys, null, null)->verify($s['snapshot'], deep: true);
|
||||
}
|
||||
}
|
||||
|
||||
/** Delegates to a real backend, but getRange() throws for the first $failures calls. */
|
||||
final class FlakyRangeBackend implements IBackend {
|
||||
public function __construct(
|
||||
private IBackend $inner,
|
||||
private int $failures,
|
||||
private ?\Throwable $error = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function put(string $path, $stream): void {
|
||||
$this->inner->put($path, $stream);
|
||||
}
|
||||
|
||||
public function get(string $path) {
|
||||
return $this->inner->get($path);
|
||||
}
|
||||
|
||||
public function getRange(string $path, int $offset, int $length): string {
|
||||
if ($this->failures > 0) {
|
||||
$this->failures--;
|
||||
throw $this->error ?? new BackendException('connection reset');
|
||||
}
|
||||
return $this->inner->getRange($path, $offset, $length);
|
||||
}
|
||||
|
||||
public function exists(string $path): bool {
|
||||
return $this->inner->exists($path);
|
||||
}
|
||||
|
||||
public function move(string $from, string $to): void {
|
||||
$this->inner->move($from, $to);
|
||||
}
|
||||
|
||||
public function list(string $prefix): array {
|
||||
return $this->inner->list($prefix);
|
||||
}
|
||||
|
||||
public function listFiles(string $dir): array {
|
||||
return $this->inner->listFiles($dir);
|
||||
}
|
||||
|
||||
public function delete(string $path): void {
|
||||
$this->inner->delete($path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,13 @@
|
||||
declare(strict_types=1);
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
// nextcloud/ocp ships the public interfaces without an autoloader; tests that mock them need one.
|
||||
spl_autoload_register(static function (string $class): void {
|
||||
if (str_starts_with($class, 'OCP\\')) {
|
||||
$file = __DIR__ . '/../vendor/nextcloud/ocp/' . str_replace('\\', '/', $class) . '.php';
|
||||
if (is_file($file)) {
|
||||
require $file;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user