Compare commits
38 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 54750e9742 | |||
| fe92ef70bf | |||
| b3e3054737 | |||
| c775e27dd2 | |||
| d8e772137f | |||
| bcfec17cfe | |||
| b309081d1a | |||
| 5f8d5faee7 | |||
| 4fa7cbf6ef | |||
| f4abfe0a8d | |||
| fe1a289c2d | |||
| ab3265e42c | |||
| 466cff8857 | |||
| 6bba0b59fc | |||
| 62766e1e71 | |||
| bfd54447b0 | |||
| 0b50bb6b81 | |||
| f415bab519 | |||
| 27eb0b32fc | |||
| b1d4f6a469 | |||
| fe7ad9b4b5 | |||
| 695d972785 | |||
| 972a52e665 | |||
| 7c08a10621 | |||
| 209520165e | |||
| 9554e774e5 | |||
| 8b2dca28e3 | |||
| 5742f77253 | |||
| 550249570b | |||
| 0eb23f56e7 | |||
| a0c1f1cd58 | |||
| 26cc9a897e | |||
| fe0385ad8c | |||
| b37a953cfd | |||
| b89b5b5fa6 | |||
| 16e09f4fde | |||
| 45069994e9 | |||
| 0f4a238414 |
+119
-3
@@ -2,9 +2,120 @@
|
|||||||
|
|
||||||
All notable changes to NG Backup are documented here. The format follows
|
All notable changes to NG Backup are documented here. The format follows
|
||||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows the stages in
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows the stages in
|
||||||
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric (Nextcloud's
|
`PLAN.md` section 9. The `<version>` in `appinfo/info.xml` is plain SemVer-numeric on purpose:
|
||||||
own `info.xsd` doesn't allow a `-alpha`/`-beta`/`-rc` suffix there); the stability stage is instead
|
the app store accepts a `-beta` suffix, but Nextcloud servers then only offer the release on the
|
||||||
carried by the git tag, the release, and this file.
|
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.4-beta.5] - 2026-10-09
|
||||||
|
|
||||||
|
Bugfix release: faster restores.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Restores and `occ backup:verify --deep` fetched every blob with its own request, so on a
|
||||||
|
remote location they ran far below the speed of the connection. The blobs of a pack are now
|
||||||
|
read from one open stream, in the order they were written. Restoring 86 MB in 202 files from a
|
||||||
|
Hetzner Storage Box over SMB went from 0.78 MB/s to 4.2 MB/s, the speed of reading the packs
|
||||||
|
as plain files over that connection.
|
||||||
|
- Reading from an SFTP location waited a full round trip for every 256 KiB, whatever the line
|
||||||
|
speed. Reads now keep up to 8 requests in flight. Restoring 1.97 GB in 500 files from a Hetzner
|
||||||
|
Storage Box (47 ms away) took 694 s before the fix above, 499 s with it and 142 s with both
|
||||||
|
(2.8 → 3.9 → 13.9 MB/s); a full 51 GB restore ran at 13.0 MB/s. When a server error
|
||||||
|
interrupts such a read, it goes on from the same position with Nextcloud's own SFTP stream;
|
||||||
|
after three failing files in a row, or on a Nextcloud whose SFTP library lacks what this needs,
|
||||||
|
only Nextcloud's own stream is used.
|
||||||
|
- README: a WebDAV location on a slow upload needs a higher `davstorage.request_timeout`.
|
||||||
|
|
||||||
|
## [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).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- App store listing: screenshots, website, repository and documentation link in `appinfo/info.xml`.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Restore: the "new folder" option showed `"Restored "` without the date pattern.
|
||||||
|
- Restore: the restore-point dropdown cut off the time.
|
||||||
|
- Settings: the passphrase actions are on one line instead of stacked.
|
||||||
|
- `docs/HOWTO.md`: the SFTP example used a non-existent auth mechanism (`sftp::password`
|
||||||
|
instead of `password::password`).
|
||||||
|
|
||||||
|
## [0.9.0-beta.1] - 2026-10-07
|
||||||
|
|
||||||
|
First beta. Tested thoroughly and usable **alongside** your current backup, but **not yet a
|
||||||
|
drop-in replacement** for it. We test what we can ourselves (Nextcloud 32–35, SQLite, MariaDB and
|
||||||
|
PostgreSQL, S3, SFTP, FTP, SMB and NFS), but we need field experience from other setups: please
|
||||||
|
report what works and what doesn't, especially on large instances and on storage providers not
|
||||||
|
listed in the README.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- Disaster recovery: `occ backup:key:import` and `occ backup:restore:full` restore the database,
|
||||||
|
data directory and config onto a fresh installation of the same major version.
|
||||||
|
- Per-user backup and restore via user_migration: `occ backup:user:backup` and
|
||||||
|
`occ backup:user:restore` (replace the account after a safety export, or restore as
|
||||||
|
`<uid>-bak`), also onto a server where the user does not exist yet. The commands ask before
|
||||||
|
installing user_migration (or take `--install-user-migration`).
|
||||||
|
- Checksum audit: `occ backup:verify`, shallow or `--deep`, for snapshots and per-user exports.
|
||||||
|
- Retention for per-user exports: `occ backup:retention --user-last=N`.
|
||||||
|
- FTP and SMB covered by the integration tests; NFS through the `local` backend on a mounted share.
|
||||||
|
- `docs/HOWTO.md`: step by step from setup to a tested restore.
|
||||||
|
- Warnings when a backup finishes with unreadable files or folders, or with files that kept
|
||||||
|
changing while being read (log, `occ`, notification, snapshot).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- `backup:restore:full` refuses on an installation with more than one user and asks first;
|
||||||
|
`backup:user:restore --mode=replace` asks before deleting an existing account (`--force` for
|
||||||
|
scripts in both cases).
|
||||||
|
- A failed scheduled backup is retried at most three times a day, an hour apart.
|
||||||
|
- The recovery kit carries the deletion delay; the kit file is created owner-only.
|
||||||
|
- Adding a location through the web API needs password confirmation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- Exports of users whose id contains `.part-` could be deleted by cleanup on remote locations.
|
||||||
|
- Cleanup no longer frees the data of a per-user export whose manifest is missing.
|
||||||
|
- Crash safety of moving snapshots to and from the trash, of catalog writes by concurrent
|
||||||
|
backups and per-user exports, and of short uploads.
|
||||||
|
- A file that keeps changing no longer makes a backup restart forever.
|
||||||
|
- Long restores and exports keep their lock on the location alive.
|
||||||
|
- Floating-point database columns are dumped exactly.
|
||||||
|
|
||||||
|
### Known limitations
|
||||||
|
See "Known limitations" in the README: memory use on very large instances, never two servers
|
||||||
|
writing to one location, removing a passphrase slot does not revoke access, no rollback detection
|
||||||
|
before a disaster recovery, and a few smaller points. None of them block the road to 1.0.0; they
|
||||||
|
are on the backlog in `PLAN.md`.
|
||||||
|
|
||||||
## [0.1.0-alpha.1] - 2026-10-05
|
## [0.1.0-alpha.1] - 2026-10-05
|
||||||
|
|
||||||
@@ -43,4 +154,9 @@ and not implemented yet.
|
|||||||
- See the "Storage backend experiences" section of the README for provider-specific quirks found
|
- See the "Storage backend experiences" section of the README for provider-specific quirks found
|
||||||
so far (e.g. Hetzner Storage Box sub-accounts).
|
so far (e.g. Hetzner Storage Box sub-accounts).
|
||||||
|
|
||||||
|
[0.9.4-beta.5]: https://github.com/dillardblom/ng_backup/releases/tag/v0.9.4-beta.5
|
||||||
|
[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
|
[0.1.0-alpha.1]: https://github.com/dillardblom/ng_backup/releases/tag/v0.1.0-alpha.1
|
||||||
|
|||||||
@@ -359,6 +359,24 @@ minimale volledige restore (`occ backup:restore:full` op een verse installatie)
|
|||||||
Backlog: (a) de originele weergavenaam herstellen (het label uit de export is niet getest als
|
Backlog: (a) de originele weergavenaam herstellen (het label uit de export is niet getest als
|
||||||
weergavenaam); (b) de interne `migrated-`-naam hernoemen (vereist directe databasewijziging, niet voor de beta).
|
weergavenaam); (b) de interne `migrated-`-naam hernoemen (vereist directe databasewijziging, niet voor de beta).
|
||||||
|
|
||||||
|
**Backlog uit de pre-beta review (2026-10-07)** — geen belemmering voor 0.9.0-beta of 1.0.0, staat
|
||||||
|
in README "Known limitations":
|
||||||
|
1. Geheugen bij grote instanties (>1 TB, miljoenen bestanden): blob-index, prune-set, browse van een
|
||||||
|
snapshot in de web-UI, `trashExtras` bij restore en het manifest van een user-export staan volledig
|
||||||
|
in het geheugen; streaming/paginering nodig.
|
||||||
|
2. Twee servers op één locatie: catalogus kan breken (generatie overschreven, of anchor >200 generaties
|
||||||
|
achter). Docs-waarschuwing staat er; mogelijk later een echte put-if-absent per backend.
|
||||||
|
3. Sleutelrotatie: een slot verwijderen trekt geen toegang in (master-key blijft gelijk).
|
||||||
|
4. Rollback vóór DR niet detecteerbaar: referentie-generatie en -hash in de kit opnemen of tonen.
|
||||||
|
5. Kit-velden buiten `wrapped_key` (o.a. `delete_delay_days`) zijn niet geauthenticeerd; nu alleen getoond.
|
||||||
|
6. Repository-id in de AD van objecten (formaatwijziging).
|
||||||
|
7. Wachtwoorden van locaties via `--option-file` of omgevingsvariabele i.p.v. de commandoregel.
|
||||||
|
8. Tijdelijke restore-bestanden niet via de Files-API verwijderen (komen nu in de prullenbak).
|
||||||
|
9. DB-restore in foreign-key-volgorde (cascades).
|
||||||
|
10. Packs zonder index (index-upload mislukt) opruimen.
|
||||||
|
11. `backup:restore:full` in onderhoudsmodus en hervatbaar maken; nu: opnieuw draaien met `--force`.
|
||||||
|
12. Config/rename op FTP/SMB-servers die geen rename over een bestaand bestand toestaan.
|
||||||
|
|
||||||
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
|
Doorgeschoven naar **v1.3.0** (fase 3, niet meer fase 2): in-place volledige restore
|
||||||
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
|
(eigen restore-modus i.p.v. onderhoudsmodus) en zelfbediening voor gebruikers (eigen bestanden
|
||||||
terugzetten vanuit de Files-app).
|
terugzetten vanuit de Files-app).
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ 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
|
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.)
|
ng_backup, and applies to any Nextcloud app that writes to an SMB share.)
|
||||||
|
|
||||||
**Status: pre-release. Not yet tested on production.** See `PLAN.md` for the design and `docs/HOWTO.md` for a
|
**Status: beta (0.9.4).** Tested thoroughly, usable alongside your current backup, but not yet a
|
||||||
step-by-step guide from setup to a tested restore.
|
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.
|
||||||
|
|
||||||
License: AGPL-3.0-or-later.
|
License: AGPL-3.0-or-later.
|
||||||
|
|
||||||
@@ -62,7 +63,8 @@ All backups of an installation are encrypted with one master key.
|
|||||||
a single slot is possible, but then that one passphrase is a single point of failure.
|
a single slot is possible, but then that one passphrase is a single point of failure.
|
||||||
- When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download
|
- When a holder leaves, give their slot a new passphrase (`occ backup:key:slot:replace`), download
|
||||||
the new kit and confirm it. Note: an old kit together with the old passphrase still opens the
|
the new kit and confirm it. Note: an old kit together with the old passphrase still opens the
|
||||||
same master key; to revoke it completely, start new locations with a new key.
|
same master key; to revoke it completely, start new locations with a new key. This is noted on
|
||||||
|
the backlog and will be addressed in a future version (key rotation).
|
||||||
- Downloading the kit, and every change to the slots, requires the administrator's password, is
|
- Downloading the kit, and every change to the slots, requires the administrator's password, is
|
||||||
written to the audit log and is announced to all administrators.
|
written to the audit log and is announced to all administrators.
|
||||||
|
|
||||||
@@ -124,6 +126,21 @@ 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
|
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.
|
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. Over WebDAV, Nextcloud gives
|
||||||
|
every request at most 30 s by default, also an upload of a whole 32 MiB pack: on an upload
|
||||||
|
slower than about 9 Mbit/s a backup fails with `cURL error 28: Operation timed out`. Raise the
|
||||||
|
limit with `occ config:system:set davstorage.request_timeout --value=600 --type=integer`.
|
||||||
|
|
||||||
- **FTP, server-dependent absolute-path handling:** files_external's FTP backend always builds
|
- **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
|
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
|
`vsftpd` test servers (chrooted) can reject that with a generic "create directory operation
|
||||||
@@ -147,6 +164,29 @@ Contributions welcome via PR — add what you ran into with your own provider.
|
|||||||
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
mid-run behaves like any other local-path failure (the run resumes). See PLAN.md 3.5 for why NFS
|
||||||
can't be driven from PHP directly.
|
can't be driven from PHP directly.
|
||||||
|
|
||||||
|
## Known limitations
|
||||||
|
|
||||||
|
On the backlog; none of these block the beta or 1.0.0, but know them before you rely on it:
|
||||||
|
|
||||||
|
- **Very large instances** (well over 1 TB, millions of files): the blob index, cleanup, browsing a
|
||||||
|
snapshot in the web interface and per-user export manifests are held in memory. Expect high
|
||||||
|
memory use there; reports from such instances are very welcome.
|
||||||
|
- **One location, one server.** Never let two Nextcloud servers write to the same location (for
|
||||||
|
example a disaster recovery test server pointed at the production location): they can break each
|
||||||
|
other's catalog, and the location is then refused as possibly tampered with. Test a disaster
|
||||||
|
recovery against a copy of the location, or only read from it.
|
||||||
|
- **Removing a passphrase slot does not revoke access.** The master key stays the same, so someone
|
||||||
|
with an old recovery kit and that slot's passphrase can still decrypt the location if they can
|
||||||
|
read it. Key rotation is not available yet.
|
||||||
|
- **Rollback before a disaster recovery is not detected.** A fresh server has no record of the
|
||||||
|
newest catalog generation, so a location rolled back to an older state looks valid there.
|
||||||
|
- Location passwords passed with `-o password=...` are visible in the process list and shell
|
||||||
|
history while the command runs.
|
||||||
|
- Temporary files of a restore into a user's files end up in that user's trash.
|
||||||
|
- Restoring the database does not order tables by foreign keys; apps with cascading foreign keys
|
||||||
|
could lose rows (Nextcloud core does not use them).
|
||||||
|
- A pack whose index upload failed stays on the location until removed by hand.
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
| Command | Purpose |
|
| Command | Purpose |
|
||||||
|
|||||||
+14
-6
@@ -4,17 +4,25 @@
|
|||||||
<id>ng_backup</id>
|
<id>ng_backup</id>
|
||||||
<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[Alpha: feature-complete for scheduled, encrypted, incremental backups to
|
<description><![CDATA[Beta: scheduled, encrypted, incremental backups of the database, config and data
|
||||||
S3/SFTP/WebDAV/local, with restore of files and folders from the web interface and occ. Meant for
|
directory to S3, SFTP, WebDAV, FTP, SMB or a local path, with restore of files and folders, of
|
||||||
testing on a copy of production (dev, test, staging) -- not yet for production use, and full
|
single users (via user_migration) and of the whole server onto a fresh installation. Tested
|
||||||
disaster recovery is not implemented yet. See the recovery kit section of the README before
|
thoroughly and usable alongside your current backup, but not yet a drop-in replacement for it.
|
||||||
relying on this for anything.]]></description>
|
See the README for known limitations and the recovery kit before relying on it.]]></description>
|
||||||
<version>0.1.0</version>
|
<version>0.9.4</version>
|
||||||
<licence>agpl</licence>
|
<licence>agpl</licence>
|
||||||
<author>Dillard Blom</author>
|
<author>Dillard Blom</author>
|
||||||
<namespace>NgBackup</namespace>
|
<namespace>NgBackup</namespace>
|
||||||
|
<documentation>
|
||||||
|
<admin>https://github.com/dillardblom/ng_backup/blob/main/docs/HOWTO.md</admin>
|
||||||
|
</documentation>
|
||||||
<category>tools</category>
|
<category>tools</category>
|
||||||
|
<website>https://apps.nextcloud.com/apps/ng_backup</website>
|
||||||
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
|
<bugs>https://github.com/dillardblom/ng_backup/issues</bugs>
|
||||||
|
<repository type="git">https://github.com/dillardblom/ng_backup</repository>
|
||||||
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/1-backups.png</screenshot>
|
||||||
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/2-restore.png</screenshot>
|
||||||
|
<screenshot>https://raw.githubusercontent.com/dillardblom/ng_backup/main/screenshots/3-settings.png</screenshot>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<php min-version="8.1"/>
|
<php min-version="8.1"/>
|
||||||
<lib>sodium</lib>
|
<lib>sodium</lib>
|
||||||
|
|||||||
+11
-4
@@ -14,7 +14,8 @@ occ backup:key:confirm -u <your-admin-uid> --code=<code shown> --confirm="Yes, I
|
|||||||
|
|
||||||
- The passphrase protects the backup. Keep it somewhere other than this server.
|
- The passphrase protects the backup. Keep it somewhere other than this server.
|
||||||
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
|
- Optional: extra passphrases for other people with `occ backup:key:slot:add --label="..."`.
|
||||||
Any one passphrase is enough to restore.
|
Any one passphrase is enough to restore. Removing a slot later does not revoke access for someone
|
||||||
|
who kept an old kit and that passphrase (see "Known limitations" in the README).
|
||||||
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
|
- The recovery kit (`/var/tmp/ng_backup-kit.json` above) is also required for disaster recovery. The web
|
||||||
user writes it there; then move it off the server as root, next to the passphrase, and delete the
|
user writes it there; then move it off the server as root, next to the passphrase, and delete the
|
||||||
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
|
copy in `/var/tmp`. Without the kit and a passphrase, the backup cannot be read.
|
||||||
@@ -31,7 +32,7 @@ Examples:
|
|||||||
|
|
||||||
```
|
```
|
||||||
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=<bucket> -o key=<key> -o secret=<secret>
|
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=<bucket> -o key=<key> -o secret=<secret>
|
||||||
occ backup:target:add nas sftp -a sftp::password -o host=<host> -o user=<user> -o password=<pw> -o root=home
|
occ backup:target:add nas sftp -a password::password -o host=<host> -o user=<user> -o password=<pw> -o root=home
|
||||||
occ backup:target:add local local -a null::null -o datadir=/mnt/backup
|
occ backup:target:add local local -a null::null -o datadir=/mnt/backup
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -102,7 +103,9 @@ occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
|||||||
use, add `--install-user-migration` to approve that explicitly. The command stops after the
|
use, add `--install-user-migration` to approve that explicitly. The command stops after the
|
||||||
installation; run it once more.
|
installation; run it once more.
|
||||||
- `replace` first makes a safety export of the current account, then recreates the user under the
|
- `replace` first makes a safety export of the current account, then recreates the user under the
|
||||||
same id. `as-backup` restores into `<uid>-bak` and leaves the original untouched.
|
same id. When the account exists, the command asks first; in scripts, add `--force`. Shares to
|
||||||
|
others and data of apps without a user_migration migrator are not restored, so prefer
|
||||||
|
`as-backup` when in doubt: it restores into `<uid>-bak` and leaves the original untouched.
|
||||||
- A restore onto a server where the user does not exist works. Calendars and address books get an
|
- A restore onto a server where the user does not exist works. Calendars and address books get an
|
||||||
internal name with the prefix `migrated-` (for example `migrated-personal`). That prefix comes from
|
internal name with the prefix `migrated-` (for example `migrated-personal`). That prefix comes from
|
||||||
user_migration and cannot be turned off. The user can change the display name of these calendars and
|
user_migration and cannot be turned off. The user can change the display name of these calendars and
|
||||||
@@ -115,7 +118,8 @@ occ backup:user:restore offsite users/<uid>/<exportId> --mode=as-backup
|
|||||||
## 7. Disaster recovery: restore the whole server
|
## 7. Disaster recovery: restore the whole server
|
||||||
|
|
||||||
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
|
Use a fresh Nextcloud installation of the same major version, with NG Backup installed and
|
||||||
the same location available:
|
the same location available. For a test, use a **copy** of the location: two servers writing to one
|
||||||
|
location break each other's catalog.
|
||||||
|
|
||||||
```
|
```
|
||||||
occ backup:key:import /path/ng_backup-kit.json --passphrase-file=/path/passphrase
|
occ backup:key:import /path/ng_backup-kit.json --passphrase-file=/path/passphrase
|
||||||
@@ -124,6 +128,9 @@ occ backup:list offsite # the snapshots must be lis
|
|||||||
occ backup:restore:full offsite <snapshot>
|
occ backup:restore:full offsite <snapshot>
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The command refuses on an installation that already has more than one user, and asks before it
|
||||||
|
starts; `--force` skips both (also needed to rerun it after it stopped halfway).
|
||||||
|
|
||||||
Afterwards, check that users, files and the database are complete, and run a backup of the restored
|
Afterwards, check that users, files and the database are complete, and run a backup of the restored
|
||||||
server. The restored installation keeps its own app configuration and secret.
|
server. The restored installation keeps its own app configuration and secret.
|
||||||
|
|
||||||
|
|||||||
@@ -37,5 +37,15 @@ interface IBackend {
|
|||||||
/** @return list<string> object paths below $prefix (recursive) */
|
/** @return list<string> object paths below $prefix (recursive) */
|
||||||
public function list(string $prefix): array;
|
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;
|
public function delete(string $path): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,11 +33,16 @@ final class LocalBackend implements IBackend {
|
|||||||
if ($out === false) {
|
if ($out === false) {
|
||||||
throw new BackendException('Cannot write ' . $path);
|
throw new BackendException('Cannot write ' . $path);
|
||||||
}
|
}
|
||||||
|
$expected = StorageBackend::remaining($stream);
|
||||||
if (stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
|
if (stream_copy_to_stream($stream, $out) === false || !fflush($out)) {
|
||||||
throw new BackendException('Write failed for ' . $path);
|
throw new BackendException('Write failed for ' . $path);
|
||||||
}
|
}
|
||||||
fclose($out);
|
fclose($out);
|
||||||
$out = false;
|
$out = false;
|
||||||
|
clearstatcache(true, $tmp);
|
||||||
|
if ($expected !== null && filesize($tmp) !== $expected) {
|
||||||
|
throw new BackendException('Incomplete write for ' . $path);
|
||||||
|
}
|
||||||
if (!rename($tmp, $target)) {
|
if (!rename($tmp, $target)) {
|
||||||
throw new BackendException('Cannot finalise ' . $path);
|
throw new BackendException('Cannot finalise ' . $path);
|
||||||
}
|
}
|
||||||
@@ -94,7 +99,7 @@ final class LocalBackend implements IBackend {
|
|||||||
$result = [];
|
$result = [];
|
||||||
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
|
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS));
|
||||||
foreach ($it as $file) {
|
foreach ($it as $file) {
|
||||||
if ($file->isFile() && !str_contains($file->getFilename(), '.part-')) {
|
if ($file->isFile() && !StorageBackend::isTemporary($file->getFilename())) {
|
||||||
$result[] = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
|
$result[] = ltrim(substr($file->getPathname(), strlen($this->root)), '/');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +107,25 @@ final class LocalBackend implements IBackend {
|
|||||||
return $result;
|
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 {
|
public function delete(string $path): void {
|
||||||
@unlink($this->abs($path));
|
@unlink($this->abs($path));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,412 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Backend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read stream for files_external's SFTP storage that keeps several READ requests in flight.
|
||||||
|
*
|
||||||
|
* files_external's own read stream asks for the next 256 KiB only after the previous one has
|
||||||
|
* arrived, so every request costs a full round trip: about 5 MB/s at 47 ms to a Hetzner Storage
|
||||||
|
* Box, whatever the line speed. With 8 requests in flight the same connection reached 15 MB/s.
|
||||||
|
* phpseclib's get() pipelines too, but only into memory or a local file, and only from offset 0.
|
||||||
|
*
|
||||||
|
* Talks to the storage's phpseclib connection through the same internal methods files_external's
|
||||||
|
* read stream uses (_send_sftp_packet, _get_sftp_packet, _realpath); open() returns null when the
|
||||||
|
* connection does not offer them (phpseclib 3 does not), and StorageBackend then falls back to the
|
||||||
|
* storage's own stream. A restore must rather be slow than fail: when a read fails halfway, the
|
||||||
|
* stream goes on from the same position with the storage's own stream. After MAX_FAILURES streams
|
||||||
|
* in a row failed, open() returns null for the rest of the process; one that works resets the count.
|
||||||
|
* Like that stream, it owns the connection while open: no other request may go over the same
|
||||||
|
* connection until it is closed (PackReaderGuard takes care of that for the repository).
|
||||||
|
*/
|
||||||
|
final class SftpReadStream {
|
||||||
|
private const PROTOCOL = 'ngbsftpread';
|
||||||
|
/** Requests in flight once a stream reads sequentially; it starts at one and doubles. */
|
||||||
|
public const DEPTH = 8;
|
||||||
|
/** OpenSSH and the Hetzner Storage Box both answer at most 255 KiB per READ. */
|
||||||
|
private const CHUNK = 261120;
|
||||||
|
/** Every SFTP server must handle packets of this size (draft-ietf-secsh-filexfer-02, section 3). */
|
||||||
|
private const MIN_CHUNK = 32768;
|
||||||
|
|
||||||
|
private const FXP_OPEN = 3;
|
||||||
|
private const FXP_CLOSE = 4;
|
||||||
|
private const FXP_READ = 5;
|
||||||
|
private const FXP_STATUS = 101;
|
||||||
|
private const FXP_HANDLE = 102;
|
||||||
|
private const FXP_DATA = 103;
|
||||||
|
private const FXF_READ = 1;
|
||||||
|
private const FX_EOF = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* READ size per connection, lowered when a server answers less than asked and then goes on, so
|
||||||
|
* later streams over that connection ask for no more than it gives.
|
||||||
|
*
|
||||||
|
* @var \WeakMap<object, int>|null
|
||||||
|
*/
|
||||||
|
private static ?\WeakMap $chunks = null;
|
||||||
|
private static int $requestId = 0x4e470000;
|
||||||
|
/** Streams in a row that failed; at MAX_FAILURES only the storage's own stream is used. */
|
||||||
|
private static int $failures = 0;
|
||||||
|
public const MAX_FAILURES = 3;
|
||||||
|
/** Set by stream_open() when the connection failed rather than the file. */
|
||||||
|
private static bool $openBroke = false;
|
||||||
|
|
||||||
|
/** @var resource|null set by PHP for stream wrappers */
|
||||||
|
public $context;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The phpseclib connection. PHP creates stream wrappers without constructor arguments; it is
|
||||||
|
* set in stream_open(), and no other method is called when that fails.
|
||||||
|
*
|
||||||
|
* @psalm-suppress PropertyNotSetInConstructor
|
||||||
|
*/
|
||||||
|
private object $sftp;
|
||||||
|
/** The storage and the path on it, to fall back to the storage's own stream. */
|
||||||
|
private ?object $storage = null;
|
||||||
|
private string $file = '';
|
||||||
|
/** @var resource|null the storage's own stream, once this one has failed */
|
||||||
|
private $fallback = null;
|
||||||
|
private string $handle = '';
|
||||||
|
/** Position of the reader: the first byte of $buffer. */
|
||||||
|
private int $pos = 0;
|
||||||
|
/** Offset of the next READ to send. */
|
||||||
|
private int $next = 0;
|
||||||
|
private string $buffer = '';
|
||||||
|
/** @var list<array{int, int, int}> request id, offset, length, in the order sent */
|
||||||
|
private array $inFlight = [];
|
||||||
|
private int $depth = 1;
|
||||||
|
private bool $eof = false;
|
||||||
|
/** This stream gave up on its own requests. */
|
||||||
|
private bool $failed = false;
|
||||||
|
/** The connection is in an unknown state: nothing more is sent over it or read from it. */
|
||||||
|
private bool $broken = false;
|
||||||
|
/** Length of the last short answer, until the next answer tells whether it was the end of the file. */
|
||||||
|
private ?int $shortAnswer = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param object $storage files_external's SFTP storage (getConnection(), getRoot())
|
||||||
|
* @return resource|false|null the stream, false when the file cannot be opened, null when the
|
||||||
|
* connection does not offer what this stream needs
|
||||||
|
* @throws BackendException when the connection failed after the OPEN request went out: the
|
||||||
|
* storage's own stream must not be tried on it, as it would take a
|
||||||
|
* late answer to that request for its own
|
||||||
|
*/
|
||||||
|
public static function open(object $storage, string $path) {
|
||||||
|
if (self::$failures >= self::MAX_FAILURES) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (!is_callable([$storage, 'getConnection']) || !is_callable([$storage, 'getRoot'])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$sftp = $storage->getConnection();
|
||||||
|
// is_callable and get_object_vars from here only see what is public: phpseclib 3 has
|
||||||
|
// methods of these names neither public nor at all.
|
||||||
|
if (!is_object($sftp) || !is_callable([$sftp, '_send_sftp_packet']) || !is_callable([$sftp, '_get_sftp_packet'])
|
||||||
|
|| !is_callable([$sftp, '_realpath']) || !array_key_exists('packet_type', get_object_vars($sftp))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!in_array(self::PROTOCOL, stream_get_wrappers(), true)) {
|
||||||
|
stream_wrapper_register(self::PROTOCOL, self::class);
|
||||||
|
}
|
||||||
|
$root = rtrim((string)$storage->getRoot(), '/');
|
||||||
|
$context = stream_context_create([self::PROTOCOL => [
|
||||||
|
'session' => $sftp,
|
||||||
|
'path' => $root . '/' . ltrim($path, '/'),
|
||||||
|
'storage' => $storage,
|
||||||
|
'file' => $path,
|
||||||
|
]]);
|
||||||
|
self::$openBroke = false;
|
||||||
|
$fh = @fopen(self::PROTOCOL . '://file', 'r', false, $context);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
// nothing was sent yet
|
||||||
|
self::$failures++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if ($fh === false && self::openBroke()) {
|
||||||
|
throw new BackendException('SFTP connection failed while opening ' . $path);
|
||||||
|
}
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Test hook: forget the learned READ sizes and earlier failures. */
|
||||||
|
public static function reset(): void {
|
||||||
|
self::$chunks = null;
|
||||||
|
self::$failures = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_open(string $url, string $mode, int $options, ?string &$openedPath): bool {
|
||||||
|
$options = stream_context_get_options($this->context ?? stream_context_get_default())[self::PROTOCOL] ?? [];
|
||||||
|
if (!isset($options['session'], $options['path']) || !is_object($options['session'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->sftp = $options['session'];
|
||||||
|
$storage = $options['storage'] ?? null;
|
||||||
|
$this->storage = is_object($storage) ? $storage : null;
|
||||||
|
$this->file = (string)($options['file'] ?? '');
|
||||||
|
try {
|
||||||
|
$remote = $this->sftp->_realpath((string)$options['path']);
|
||||||
|
if (!is_string($remote)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$id = self::nextId();
|
||||||
|
if (!$this->sftp->_send_sftp_packet(self::FXP_OPEN, pack('Na*N2', strlen($remote), $remote, self::FXF_READ, 0), $id)) {
|
||||||
|
return $this->connectionFailed();
|
||||||
|
}
|
||||||
|
$response = $this->sftp->_get_sftp_packet($id);
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return $this->connectionFailed();
|
||||||
|
}
|
||||||
|
if (!is_string($response)) {
|
||||||
|
return $this->connectionFailed();
|
||||||
|
}
|
||||||
|
if ($this->sftp->packet_type !== self::FXP_HANDLE) {
|
||||||
|
return false; // an answer, but no handle: the file cannot be opened
|
||||||
|
}
|
||||||
|
$this->handle = substr($response, 4);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Not a missing file but a connection that does not behave as expected. */
|
||||||
|
private function connectionFailed(): bool {
|
||||||
|
self::$failures++;
|
||||||
|
self::$openBroke = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the last stream_open() failed on the connection; a call, as fopen() sets it. */
|
||||||
|
private static function openBroke(): bool {
|
||||||
|
return self::$openBroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_read(int $count): string|false {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
return fread($this->fallback, $count);
|
||||||
|
}
|
||||||
|
while (strlen($this->buffer) < $count && !$this->eof) {
|
||||||
|
if (!$this->receive()) {
|
||||||
|
return $this->useFallback() ? $this->stream_read($count) : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$data = substr($this->buffer, 0, $count);
|
||||||
|
$this->buffer = substr($this->buffer, strlen($data));
|
||||||
|
$this->pos += strlen($data);
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_eof(): bool {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
return feof($this->fallback);
|
||||||
|
}
|
||||||
|
return $this->eof && $this->buffer === '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_tell(): int {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
$pos = ftell($this->fallback);
|
||||||
|
return $pos === false ? $this->pos : $pos;
|
||||||
|
}
|
||||||
|
return $this->pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_seek(int $offset, int $whence = SEEK_SET): bool {
|
||||||
|
if ($this->fallback !== null) {
|
||||||
|
return fseek($this->fallback, $offset, $whence) === 0;
|
||||||
|
}
|
||||||
|
if ($whence === SEEK_CUR) {
|
||||||
|
$offset += $this->pos;
|
||||||
|
} elseif ($whence !== SEEK_SET) {
|
||||||
|
return false; // the size is never asked for, to save a round trip
|
||||||
|
}
|
||||||
|
if ($offset < 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->failed) {
|
||||||
|
$this->pos = $offset;
|
||||||
|
return $this->useFallback();
|
||||||
|
}
|
||||||
|
if ($offset >= $this->pos && $offset - $this->pos <= strlen($this->buffer)) {
|
||||||
|
$this->buffer = substr($this->buffer, $offset - $this->pos);
|
||||||
|
} else {
|
||||||
|
$this->drain();
|
||||||
|
$this->buffer = '';
|
||||||
|
$this->next = $offset;
|
||||||
|
$this->eof = false;
|
||||||
|
$this->depth = 1;
|
||||||
|
$this->shortAnswer = null;
|
||||||
|
}
|
||||||
|
$this->pos = $offset;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int|string, int> */
|
||||||
|
public function stream_stat(): array {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function stream_close(): void {
|
||||||
|
if (!$this->failed) {
|
||||||
|
self::$failures = 0;
|
||||||
|
}
|
||||||
|
$fallback = $this->fallback;
|
||||||
|
$this->fallback = null;
|
||||||
|
if ($fallback !== null) {
|
||||||
|
fclose($fallback);
|
||||||
|
}
|
||||||
|
$this->closeHandle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Go on with the storage's own stream from the current position, after a server error on this
|
||||||
|
* one. The data still buffered is dropped and read again. False after a connection error, or
|
||||||
|
* when that stream cannot be opened or positioned: then the read fails as it would have
|
||||||
|
* without this class. Reopening by path is safe for a repository: packs and index files are
|
||||||
|
* never rewritten, and every object is authenticated when it is decrypted.
|
||||||
|
*/
|
||||||
|
private function useFallback(): bool {
|
||||||
|
$this->closeHandle();
|
||||||
|
$this->buffer = '';
|
||||||
|
// files_external's stream takes whatever answer comes next on the connection: after a
|
||||||
|
// connection error, answers to this stream's requests may still be on their way.
|
||||||
|
if ($this->broken || $this->storage === null || !is_callable([$this->storage, 'fopen'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$fh = $this->storage->fopen($this->file, 'r');
|
||||||
|
if (!is_resource($fh)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($this->pos > 0 && fseek($fh, $this->pos) !== 0) {
|
||||||
|
fclose($fh);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$this->fallback = $fh;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function closeHandle(): void {
|
||||||
|
$this->drain();
|
||||||
|
if ($this->handle !== '' && !$this->broken) {
|
||||||
|
try {
|
||||||
|
$id = self::nextId();
|
||||||
|
if (!$this->sftp->_send_sftp_packet(self::FXP_CLOSE, pack('Na*', strlen($this->handle), $this->handle), $id)
|
||||||
|
|| !is_string($this->sftp->_get_sftp_packet($id))) {
|
||||||
|
$this->broken = true;
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->broken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->handle = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keep the pipeline full, then take the oldest answer. False on a connection or server error. */
|
||||||
|
private function receive(): bool {
|
||||||
|
if ($this->failed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return $this->receiveOne();
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->broken = true;
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function receiveOne(): bool {
|
||||||
|
while (count($this->inFlight) < $this->depth) {
|
||||||
|
$id = self::nextId();
|
||||||
|
$length = self::chunk($this->sftp);
|
||||||
|
$packet = pack('Na*N3', strlen($this->handle), $this->handle, intdiv($this->next, 4294967296), $this->next % 4294967296, $length);
|
||||||
|
if (!$this->sftp->_send_sftp_packet(self::FXP_READ, $packet, $id)) {
|
||||||
|
$this->broken = true;
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
$this->inFlight[] = [$id, $this->next, $length];
|
||||||
|
$this->next += $length;
|
||||||
|
}
|
||||||
|
[$id, $offset, $length] = array_shift($this->inFlight);
|
||||||
|
$response = $this->sftp->_get_sftp_packet($id);
|
||||||
|
if (!is_string($response)) {
|
||||||
|
$this->broken = true;
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
if ($this->sftp->packet_type === self::FXP_DATA) {
|
||||||
|
$data = substr($response, 4);
|
||||||
|
if ($data === '') {
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
$this->buffer .= $data;
|
||||||
|
$this->depth = min(self::DEPTH, $this->depth * 2);
|
||||||
|
if ($this->shortAnswer !== null) {
|
||||||
|
// More data after a short answer: that one was the server's cap, not the end.
|
||||||
|
// Never below what every server must support: a one-off small answer is no cap.
|
||||||
|
self::$chunks ??= new \WeakMap();
|
||||||
|
self::$chunks[$this->sftp] = max(self::MIN_CHUNK, min(self::chunk($this->sftp), $this->shortAnswer));
|
||||||
|
$this->shortAnswer = null;
|
||||||
|
}
|
||||||
|
if (strlen($data) < $length) {
|
||||||
|
// Fewer bytes than asked: the end of the file, or a server that caps a READ. The
|
||||||
|
// requests behind this one assumed a full answer, so drop them and ask again from here.
|
||||||
|
$this->drain();
|
||||||
|
$this->next = $offset + strlen($data);
|
||||||
|
$this->shortAnswer = strlen($data);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$status = $this->sftp->packet_type === self::FXP_STATUS ? unpack('N', $response) : false;
|
||||||
|
if (is_array($status) && ($status[1] ?? null) === self::FX_EOF) {
|
||||||
|
$this->eof = true;
|
||||||
|
$this->shortAnswer = null;
|
||||||
|
$this->drain();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return $this->fail();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fail(): bool {
|
||||||
|
$this->drain();
|
||||||
|
if (!$this->failed) {
|
||||||
|
self::$failures++;
|
||||||
|
}
|
||||||
|
$this->failed = true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read and drop the answers still on their way, so the connection is free again. */
|
||||||
|
private function drain(): void {
|
||||||
|
$inFlight = $this->inFlight;
|
||||||
|
$this->inFlight = [];
|
||||||
|
if ($this->broken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
foreach ($inFlight as [$id]) {
|
||||||
|
if (!is_string($this->sftp->_get_sftp_packet($id))) {
|
||||||
|
$this->broken = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (\Throwable) {
|
||||||
|
$this->broken = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function chunk(object $sftp): int {
|
||||||
|
$learned = self::$chunks?->offsetExists($sftp) === true ? self::$chunks->offsetGet($sftp) : null;
|
||||||
|
return is_int($learned) ? $learned : self::CHUNK;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function nextId(): int {
|
||||||
|
self::$requestId = self::$requestId >= 0x7fffffff ? 0x4e470000 : self::$requestId + 1;
|
||||||
|
return self::$requestId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ use OCP\Files\Storage\IWriteStreamStorage;
|
|||||||
final class StorageBackend implements IBackend {
|
final class StorageBackend implements IBackend {
|
||||||
/** Object stores write whole objects atomically; other storages get a temp name + rename. */
|
/** Object stores write whole objects atomically; other storages get a temp name + rename. */
|
||||||
private bool $atomicPut;
|
private bool $atomicPut;
|
||||||
|
/** SFTP: read through SftpReadStream, which keeps several requests in flight. */
|
||||||
|
private bool $sftpReads;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private IStorage $storage,
|
private IStorage $storage,
|
||||||
@@ -25,12 +27,19 @@ final class StorageBackend implements IBackend {
|
|||||||
$this->base = trim($base, '/');
|
$this->base = trim($base, '/');
|
||||||
self::validate($this->base, true);
|
self::validate($this->base, true);
|
||||||
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
|
$this->atomicPut = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\AmazonS3::class);
|
||||||
|
$this->sftpReads = $storage->instanceOfStorage(\OCA\Files_External\Lib\Storage\SFTP::class);
|
||||||
if ($this->base !== '') {
|
if ($this->base !== '') {
|
||||||
$this->mkdirs($this->base);
|
$this->mkdirs($this->base);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A temporary upload of put(): "<name>.part-<8 hex>". */
|
||||||
|
public static function isTemporary(string $name): bool {
|
||||||
|
return preg_match('/\.part-[0-9a-f]{8}$/', $name) === 1;
|
||||||
|
}
|
||||||
|
|
||||||
public function put(string $path, $stream): void {
|
public function put(string $path, $stream): void {
|
||||||
|
$expected = self::remaining($stream);
|
||||||
$target = $this->abs($path);
|
$target = $this->abs($path);
|
||||||
$this->mkdirs(dirname($target));
|
$this->mkdirs(dirname($target));
|
||||||
$writeTo = $this->atomicPut ? $target : $target . '.part-' . bin2hex(random_bytes(4));
|
$writeTo = $this->atomicPut ? $target : $target . '.part-' . bin2hex(random_bytes(4));
|
||||||
@@ -54,6 +63,10 @@ final class StorageBackend implements IBackend {
|
|||||||
if (is_resource($stream)) {
|
if (is_resource($stream)) {
|
||||||
fclose($stream);
|
fclose($stream);
|
||||||
}
|
}
|
||||||
|
// Some adapters report success after a short write or a failed flush: check the size.
|
||||||
|
if ($expected !== null && $this->storage->filesize($writeTo) !== $expected) {
|
||||||
|
throw new BackendException('Incomplete write for ' . $path);
|
||||||
|
}
|
||||||
if ($writeTo !== $target && !$this->storage->rename($writeTo, $target)) {
|
if ($writeTo !== $target && !$this->storage->rename($writeTo, $target)) {
|
||||||
throw new BackendException('Cannot finalise ' . $path);
|
throw new BackendException('Cannot finalise ' . $path);
|
||||||
}
|
}
|
||||||
@@ -69,6 +82,14 @@ final class StorageBackend implements IBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function get(string $path) {
|
public function get(string $path) {
|
||||||
|
if ($this->sftpReads) {
|
||||||
|
$fh = SftpReadStream::open($this->storage, $this->abs($path));
|
||||||
|
if (is_resource($fh)) {
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
// null: not supported or failing for now; false: not opened, which need not mean
|
||||||
|
// missing. Either way the storage's own stream decides.
|
||||||
|
}
|
||||||
$fh = $this->storage->fopen($this->abs($path), 'r');
|
$fh = $this->storage->fopen($this->abs($path), 'r');
|
||||||
if ($fh === false) {
|
if ($fh === false) {
|
||||||
throw new BackendException('Not found: ' . $path);
|
throw new BackendException('Not found: ' . $path);
|
||||||
@@ -123,6 +144,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 {
|
private function listInto(string $dir, array &$result): void {
|
||||||
if (!$this->storage->is_dir($dir)) {
|
if (!$this->storage->is_dir($dir)) {
|
||||||
return;
|
return;
|
||||||
@@ -132,19 +175,39 @@ final class StorageBackend implements IBackend {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
while (($name = readdir($dh)) !== false) {
|
while (($name = readdir($dh)) !== false) {
|
||||||
if ($name === '.' || $name === '..' || str_contains($name, '.part-')) {
|
if ($name === '.' || $name === '..') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$child = $dir . '/' . $name;
|
$child = $dir . '/' . $name;
|
||||||
if ($this->storage->is_dir($child)) {
|
if ($this->storage->is_dir($child)) {
|
||||||
$this->listInto($child, $result);
|
$this->listInto($child, $result);
|
||||||
} else {
|
} elseif (!self::isTemporary($name)) {
|
||||||
$result[] = $child;
|
$result[] = $child;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
closedir($dh);
|
closedir($dh);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes left to read in a local $stream (php://temp, php://memory or a plain file), or null:
|
||||||
|
* stream wrappers of remote storages do not report a reliable size.
|
||||||
|
*/
|
||||||
|
public static function remaining(mixed $stream): ?int {
|
||||||
|
if (!is_resource($stream)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$meta = stream_get_meta_data($stream);
|
||||||
|
if (!($meta['seekable'] ?? false) || !in_array($meta['wrapper_type'] ?? '', ['PHP', 'plainfile'], true)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
$stat = fstat($stream);
|
||||||
|
$pos = ftell($stream);
|
||||||
|
if ($stat === false || $pos === false) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return max(0, $stat['size'] - $pos);
|
||||||
|
}
|
||||||
|
|
||||||
private function mkdirs(string $dir): void {
|
private function mkdirs(string $dir): void {
|
||||||
if ($dir === '' || $dir === '.' || $this->storage->is_dir($dir)) {
|
if ($dir === '' || $dir === '.' || $this->storage->is_dir($dir)) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ final class BackupJob extends TimedJob {
|
|||||||
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
$this->setTimeSensitivity(IJob::TIME_SENSITIVE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private const MAX_DAILY_ATTEMPTS = 3;
|
||||||
|
|
||||||
protected function run($argument): void {
|
protected function run($argument): void {
|
||||||
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
$budget = PHP_SAPI === 'cli' ? 240.0 : 20.0;
|
||||||
$deadline = microtime(true) + $budget;
|
$deadline = microtime(true) + $budget;
|
||||||
$this->startScheduled();
|
$this->startScheduled();
|
||||||
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
foreach ($this->runs->findRunning(null, BackupService::KIND_FULL) as $run) {
|
||||||
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
while (microtime(true) < $deadline && $run->getStatus() === 'running') {
|
||||||
$run = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
$next = $this->backups->step($run, min($deadline, microtime(true) + $budget));
|
||||||
|
if ($next === $run) {
|
||||||
|
break; // step() hands back the same object when another process holds the run's lease
|
||||||
|
}
|
||||||
|
$run = $next;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->failStaleRestores();
|
$this->failStaleRestores();
|
||||||
@@ -67,10 +73,15 @@ final class BackupJob extends TimedJob {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Once a week (after Sunday's backup), apply the retention policy to non-append-only locations. */
|
/**
|
||||||
|
* Once a week, apply the retention policy to non-append-only locations: from Sunday on, at the
|
||||||
|
* first moment no backup is running, so a long Sunday backup only delays it instead of skipping
|
||||||
|
* the week.
|
||||||
|
*/
|
||||||
private function weeklyPrune(): void {
|
private function weeklyPrune(): void {
|
||||||
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
$lastWeek = $this->appConfig->getValueString(Application::APP_ID, 'last_prune_week', '');
|
||||||
if (date('N') !== '7' || $lastWeek === date('o-W') || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
|
$due = date('N') === '7' ? date('o-W') : date('o-W', (int)strtotime('last sunday'));
|
||||||
|
if (strcmp($lastWeek, $due) >= 0 || $this->runs->findRunning(null, BackupService::KIND_FULL) !== []) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
foreach ($this->targets->list() as $t) {
|
foreach ($this->targets->list() as $t) {
|
||||||
@@ -83,7 +94,7 @@ final class BackupJob extends TimedJob {
|
|||||||
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
$this->logger->warning('NG Backup: cleanup of ' . $t->getName() . ' failed: ' . $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', date('o-W'));
|
$this->appConfig->setValueString(Application::APP_ID, 'last_prune_week', $due);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
/** Daily schedule: appconfig ng_backup/schedule_time "HH:MM" (server time); empty = no schedule. */
|
||||||
@@ -92,7 +103,7 @@ final class BackupJob extends TimedJob {
|
|||||||
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
if (!preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $at)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$todayAt = strtotime(date('Y-m-d') . ' ' . $at);
|
$todayAt = (int)strtotime(date('Y-m-d') . ' ' . $at);
|
||||||
if (time() < $todayAt) {
|
if (time() < $todayAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -101,6 +112,12 @@ final class BackupJob extends TimedJob {
|
|||||||
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
if ($this->runs->findRunning($t->getId(), BackupService::KIND_FULL) !== [] || ($last !== null && $last->getStartedAt() >= $todayAt)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// After a failure: retry at most MAX_DAILY_ATTEMPTS times a day, an hour apart, instead
|
||||||
|
// of starting (and notifying about) a new full run every five minutes all day.
|
||||||
|
$today = $this->runs->startedSince($t->getId(), BackupService::KIND_FULL, $todayAt);
|
||||||
|
if (count($today) >= self::MAX_DAILY_ATTEMPTS || ($today !== [] && time() - (int)$today[0]->getFinishedAt() < 3600)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$this->backups->start($t, 'scheduled');
|
$this->backups->start($t, 'scheduled');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -76,6 +76,9 @@ HELP);
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$output->writeln('Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
$output->writeln('Backup key imported, fingerprint <info>' . $this->keys->fingerprint() . '</info>.');
|
||||||
|
// Read from the kit file, which is not authenticated beyond the wrapped key: show it so a
|
||||||
|
// wrong or altered value stands out.
|
||||||
|
$output->writeln('Deletion delay for forgotten snapshots: <info>' . $this->keys->deleteDelayDays() . '</info> days (from the kit).');
|
||||||
$output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.');
|
$output->writeln('Next: connect the backup location with the same details as before (<info>occ backup:target:add</info>), then <info>occ backup:list</info> and <info>occ backup:restore:full</info>.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-2
@@ -39,11 +39,24 @@ final class KeyKit extends Command {
|
|||||||
$output->write($json, false, OutputInterface::OUTPUT_RAW);
|
$output->write($json, false, OutputInterface::OUTPUT_RAW);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
if (file_put_contents($file, $json) === false) {
|
// Created owner-only from the start (no window where it is world-readable), never over an
|
||||||
|
// existing file or symlink someone else may have placed at a predictable path.
|
||||||
|
$old = umask(0077);
|
||||||
|
try {
|
||||||
|
$fh = @fopen($file, 'xb');
|
||||||
|
} finally {
|
||||||
|
umask($old);
|
||||||
|
}
|
||||||
|
if ($fh === false) {
|
||||||
|
$output->writeln("<error>Cannot create $file (it must not exist yet)</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$ok = fwrite($fh, $json) === strlen($json);
|
||||||
|
if (!fclose($fh) || !$ok) {
|
||||||
|
@unlink($file);
|
||||||
$output->writeln("<error>Cannot write $file</error>");
|
$output->writeln("<error>Cannot write $file</error>");
|
||||||
return 1;
|
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>.");
|
$output->writeln("Recovery kit written to <info>$file</info>. Copy it off this server, then run <info>occ backup:key:confirm</info>.");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,11 @@ namespace OCA\NgBackup\Command;
|
|||||||
|
|
||||||
use OCA\NgBackup\Service\RestoreFullService;
|
use OCA\NgBackup\Service\RestoreFullService;
|
||||||
use OCA\NgBackup\Service\TargetService;
|
use OCA\NgBackup\Service\TargetService;
|
||||||
|
use OCP\IUserManager;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||||
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
|
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Output\OutputInterface;
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
@@ -18,6 +22,7 @@ final class RestoreFull extends Command {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private RestoreFullService $restoreFull,
|
private RestoreFullService $restoreFull,
|
||||||
|
private IUserManager $userManager,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -27,6 +32,7 @@ final class RestoreFull extends Command {
|
|||||||
->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation')
|
->setDescription('Disaster recovery: restore the database, data directory and config on a fresh installation')
|
||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
->addArgument('snapshot', InputArgument::REQUIRED, 'Snapshot id (see backup:list)')
|
||||||
|
->addOption('force', null, InputOption::VALUE_NONE, 'Also on an installation that already has more than one user, and without asking')
|
||||||
->setHelp(<<<'HELP'
|
->setHelp(<<<'HELP'
|
||||||
For a FRESH Nextcloud installation only, same major version as the backup, right after
|
For a FRESH Nextcloud installation only, same major version as the backup, right after
|
||||||
occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else
|
occ maintenance:install, occ backup:key:import and occ backup:target:add -- before anyone else
|
||||||
@@ -50,12 +56,29 @@ HELP);
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$force = (bool)$input->getOption('force');
|
||||||
|
// A whole-server restore replaces the database and data directory of THIS instance. On
|
||||||
|
// anything but a fresh install (only the admin from occ maintenance:install) that is
|
||||||
|
// almost certainly a mistake, so it needs --force.
|
||||||
|
$users = $this->userManager->countUsersTotal(2);
|
||||||
|
if (!$force && ($users === false || $users > 1)) {
|
||||||
|
$output->writeln('<error>This installation already has more than one user: backup:restore:full would replace its whole database and data directory. It is meant for a fresh installation. Use --force if this really is what you want.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (!$force && $input->isInteractive()) {
|
||||||
|
$q = new ConfirmationQuestion('This replaces the database, the data directory and config.php of this installation. Continue? [y/N] ', false);
|
||||||
|
if (!(new QuestionHelper())->ask($input, $output, $q)) {
|
||||||
|
$output->writeln('Cancelled.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$target = $this->targets->get($input->getArgument('target'));
|
$target = $this->targets->get($input->getArgument('target'));
|
||||||
$snapshot = (string)$input->getArgument('snapshot');
|
$snapshot = (string)$input->getArgument('snapshot');
|
||||||
$result = $this->restoreFull->restoreFull($target, $snapshot);
|
$result = $this->restoreFull->restoreFull($target, $snapshot);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
|
$output->writeln('If this happened after the restore had started, the instance may be partly restored: fix the cause and run the same command again with --force.');
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
$output->writeln(sprintf(
|
$output->writeln(sprintf(
|
||||||
@@ -65,6 +88,9 @@ HELP);
|
|||||||
$result['configKeys'],
|
$result['configKeys'],
|
||||||
$result['dataFiles'],
|
$result['dataFiles'],
|
||||||
));
|
));
|
||||||
|
if (!$result['dbConsistent']) {
|
||||||
|
$output->writeln('<comment>Warning: this snapshot\'s database dump was made over several steps (web cron), so it is not one consistent point in time. Check the instance carefully; prefer a snapshot made with system cron.</comment>');
|
||||||
|
}
|
||||||
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
$output->writeln('Next: check trusted_domains and overwritehost/overwriteprotocol match this server, then occ maintenance:repair.');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ namespace OCA\NgBackup\Command;
|
|||||||
use OCA\NgBackup\Service\TargetService;
|
use OCA\NgBackup\Service\TargetService;
|
||||||
use OCA\NgBackup\Service\UserRestoreService;
|
use OCA\NgBackup\Service\UserRestoreService;
|
||||||
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
|
use OCA\NgBackup\UserMigration\UserMigrationInstaller;
|
||||||
|
use OCP\IUserManager;
|
||||||
use Symfony\Component\Console\Command\Command;
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Helper\QuestionHelper;
|
||||||
|
use Symfony\Component\Console\Question\ConfirmationQuestion;
|
||||||
use Symfony\Component\Console\Input\InputArgument;
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
use Symfony\Component\Console\Input\InputInterface;
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
use Symfony\Component\Console\Input\InputOption;
|
use Symfony\Component\Console\Input\InputOption;
|
||||||
@@ -21,6 +24,7 @@ final class RestoreUser extends Command {
|
|||||||
private TargetService $targets,
|
private TargetService $targets,
|
||||||
private UserRestoreService $userRestore,
|
private UserRestoreService $userRestore,
|
||||||
private UserMigrationInstaller $installer,
|
private UserMigrationInstaller $installer,
|
||||||
|
private IUserManager $userManager,
|
||||||
) {
|
) {
|
||||||
parent::__construct();
|
parent::__construct();
|
||||||
}
|
}
|
||||||
@@ -31,6 +35,7 @@ final class RestoreUser extends Command {
|
|||||||
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
->addArgument('target', InputArgument::REQUIRED, 'Location name or id')
|
||||||
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)')
|
->addArgument('manifest', InputArgument::REQUIRED, 'Manifest path printed by backup:user:backup (users/<uid>/<exportId>)')
|
||||||
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
|
->addOption('mode', 'm', InputOption::VALUE_REQUIRED, 'replace or as-backup', UserRestoreService::MODE_REPLACE)
|
||||||
|
->addOption('force', null, InputOption::VALUE_NONE, 'Replace an existing account without asking (required when not interactive)')
|
||||||
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
|
->addOption('install-user-migration', null, InputOption::VALUE_NONE, 'Install and enable user_migration if it is missing (non-interactive consent)')
|
||||||
->setHelp(<<<'HELP'
|
->setHelp(<<<'HELP'
|
||||||
Never imports over a live account. Modes:
|
Never imports over a live account. Modes:
|
||||||
@@ -47,11 +52,26 @@ HELP);
|
|||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(InputInterface $input, OutputInterface $output): int {
|
protected function execute(InputInterface $input, OutputInterface $output): int {
|
||||||
|
$mode = (string)$input->getOption('mode');
|
||||||
|
$uid = explode('/', (string)$input->getArgument('manifest'))[1] ?? '';
|
||||||
|
if ($mode === UserRestoreService::MODE_REPLACE && $uid !== '' && $this->userManager->userExists($uid) && !$input->getOption('force')) {
|
||||||
|
// Replace deletes the live account (after a safety export). Shares to others and data of
|
||||||
|
// apps without a user_migration migrator do not come back, so never do it unasked.
|
||||||
|
if (!$input->isInteractive()) {
|
||||||
|
$output->writeln("<error>$uid exists: --mode=replace deletes and recreates it. Add --force, or use --mode=as-backup.</error>");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
$q = new ConfirmationQuestion("$uid exists and will be deleted and recreated from the backup (a safety export is made first; its shares to others and data of apps without a migrator are not restored). Continue? [y/N] ", false);
|
||||||
|
if (!(new QuestionHelper())->ask($input, $output, $q)) {
|
||||||
|
$output->writeln('Cancelled.');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
|
$output->writeln('<comment>Note: on restore, user_migration only imports the app settings on its own allowlist (e.g. calendar view and reminder settings); other app settings of this user are skipped. Reason: NG Backup uses user_migration\'s importer; widening the list would mean building a settings importer of our own.</comment>');
|
||||||
$target = $this->targets->get($input->getArgument('target'));
|
$target = $this->targets->get($input->getArgument('target'));
|
||||||
$this->installer->ensureEnabled($input, $output);
|
$this->installer->ensureEnabled($input, $output);
|
||||||
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), (string)$input->getOption('mode'));
|
$user = $this->userRestore->restoreUser($target, (string)$input->getArgument('manifest'), $mode);
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
$output->writeln('<error>' . $e->getMessage() . '</error>');
|
||||||
return 1;
|
return 1;
|
||||||
|
|||||||
@@ -75,6 +75,13 @@ HELP);
|
|||||||
});
|
});
|
||||||
if ($run->getStatus() === Run::DONE) {
|
if ($run->getStatus() === Run::DONE) {
|
||||||
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
|
$output->writeln(sprintf(' done in %.0fs', microtime(true) - $start));
|
||||||
|
$f = json_decode($run->getStats() ?? '{}', true)['files'] ?? [];
|
||||||
|
if (($f['unreadable'] ?? 0) > 0) {
|
||||||
|
$output->writeln(sprintf('<comment> warning: %d unreadable files or folders are missing from this backup (see the server log)</comment>', $f['unreadable']));
|
||||||
|
}
|
||||||
|
if (($f['changing'] ?? 0) > 0) {
|
||||||
|
$output->writeln(sprintf('<comment> warning: %d files kept changing and were saved as read (see the server log)</comment>', $f['changing']));
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
|
$output->writeln('<error> ' . $run->getStatus() . ': ' . $run->getError() . '</error>');
|
||||||
$failed++;
|
$failed++;
|
||||||
|
|||||||
@@ -66,7 +66,13 @@ HELP);
|
|||||||
if ($result['failed'] !== []) {
|
if ($result['failed'] !== []) {
|
||||||
$output->writeln('<error>' . count($result['failed']) . ' blob(s) failed verification (corrupted or tampered):</error> ' . implode(', ', $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>');
|
$output->writeln('<info>OK</info>');
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,7 @@ final class AdminApiController extends Controller {
|
|||||||
return new JSONResponse($this->targets->availableBackends());
|
return new JSONResponse($this->targets->availableBackends());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[PasswordConfirmationRequired]
|
||||||
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
|
public function addTarget(string $name, string $backend, string $auth, array $options = [], string $path = 'ng_backup', ?float $maxGb = null): JSONResponse {
|
||||||
try {
|
try {
|
||||||
$r = $this->targets->add($name, $backend, $auth, $options, $path);
|
$r = $this->targets->add($name, $backend, $auth, $options, $path);
|
||||||
|
|||||||
@@ -96,6 +96,12 @@ final class KeyRing {
|
|||||||
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
/** @param array{v:int, salt:string, ops:int, mem:int, nonce:string, key:string} $wrapped */
|
||||||
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
public static function unwrap(array $wrapped, #[\SensitiveParameter] string $passphrase): self {
|
||||||
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
$b64 = static fn (string $s): string => sodium_base642bin($s, SODIUM_BASE64_VARIANT_ORIGINAL);
|
||||||
|
// ops/mem come from unauthenticated JSON (kit file, repository config): bound them, so a
|
||||||
|
// damaged or crafted file cannot make the KDF use unbounded memory or time.
|
||||||
|
if ($wrapped['ops'] < SODIUM_CRYPTO_PWHASH_OPSLIMIT_INTERACTIVE || $wrapped['ops'] > SODIUM_CRYPTO_PWHASH_OPSLIMIT_SENSITIVE
|
||||||
|
|| $wrapped['mem'] < SODIUM_CRYPTO_PWHASH_MEMLIMIT_INTERACTIVE || $wrapped['mem'] > SODIUM_CRYPTO_PWHASH_MEMLIMIT_SENSITIVE) {
|
||||||
|
throw new CryptoException('Unsupported key derivation parameters (damaged or foreign key file)');
|
||||||
|
}
|
||||||
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']),
|
$kek = sodium_crypto_pwhash(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, $passphrase, $b64($wrapped['salt']),
|
||||||
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
$wrapped['ops'], $wrapped['mem'], SODIUM_CRYPTO_PWHASH_ALG_ARGON2ID13);
|
||||||
$master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek);
|
$master = sodium_crypto_secretbox_open($b64($wrapped['key']), $b64($wrapped['nonce']), $kek);
|
||||||
|
|||||||
@@ -122,6 +122,7 @@ final class StreamCipher {
|
|||||||
/** fread() may return short reads on network streams; keep reading until $length or EOF. */
|
/** fread() may return short reads on network streams; keep reading until $length or EOF. */
|
||||||
private static function readFull($in, int $length): string {
|
private static function readFull($in, int $length): string {
|
||||||
$buffer = '';
|
$buffer = '';
|
||||||
|
$empty = 0;
|
||||||
while (strlen($buffer) < $length && !feof($in)) {
|
while (strlen($buffer) < $length && !feof($in)) {
|
||||||
$chunk = fread($in, $length - strlen($buffer));
|
$chunk = fread($in, $length - strlen($buffer));
|
||||||
if ($chunk === false) {
|
if ($chunk === false) {
|
||||||
@@ -132,8 +133,15 @@ final class StreamCipher {
|
|||||||
if (feof($in)) {
|
if (feof($in)) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
// A stalled network stream returns '' without EOF forever: back off, then give up
|
||||||
|
// (about a minute) instead of spinning at full CPU until the lease expires.
|
||||||
|
if (++$empty > 600) {
|
||||||
|
throw new CryptoException('Read stalled');
|
||||||
|
}
|
||||||
|
usleep(100000);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$empty = 0;
|
||||||
$buffer .= $chunk;
|
$buffer .= $chunk;
|
||||||
}
|
}
|
||||||
return $buffer;
|
return $buffer;
|
||||||
|
|||||||
+48
-37
@@ -72,50 +72,57 @@ final class DbDumper {
|
|||||||
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
public function dumpStep(Repository $repo, array $state, float $deadline, array &$stats): array {
|
||||||
$state['steps']++;
|
$state['steps']++;
|
||||||
$sm = $this->db->createSchemaManager();
|
$sm = $this->db->createSchemaManager();
|
||||||
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
// A pack that fills up during the dump is uploaded right away; its index cache row must
|
||||||
$this->db->beginTransaction();
|
// wait until the read-only transaction is over.
|
||||||
|
$repo->holdIndexCache();
|
||||||
try {
|
try {
|
||||||
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
$this->db->setTransactionIsolation(TransactionIsolationLevel::REPEATABLE_READ);
|
||||||
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
$this->db->beginTransaction();
|
||||||
}
|
try {
|
||||||
while ($state['i'] < count($state['tables'])) {
|
if ($this->db->getDatabasePlatform() instanceof \Doctrine\DBAL\Platforms\PostgreSQLPlatform) {
|
||||||
$table = $state['tables'][$state['i']];
|
$this->db->executeStatement('SET TRANSACTION READ ONLY');
|
||||||
$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;
|
while ($state['i'] < count($state['tables'])) {
|
||||||
foreach ($this->rowLines($schema, $state['after']) as [$key, $line]) {
|
$table = $state['tables'][$state['i']];
|
||||||
$writer->write($line . "\n");
|
$schema = $sm->introspectTable($table);
|
||||||
hash_update($hash, $line . "\n");
|
$writer = $repo->blobWriter($stats);
|
||||||
$state['rows']++;
|
$hash = $state['hash'] !== null ? unserialize(base64_decode($state['hash']), ['allowed_classes' => [\HashContext::class]]) : hash_init('sha256');
|
||||||
$state['after'] = $key;
|
if ($state['after'] === null && $state['segments'] === []) {
|
||||||
if (microtime(true) >= $deadline) {
|
$writer->write($this->header($schema) . "\n");
|
||||||
$complete = false;
|
}
|
||||||
|
$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;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$state['segments'][] = $writer->finish();
|
if ($state['i'] >= count($state['tables'])) {
|
||||||
$state['bytes'] += $writer->bytes();
|
$state['sequences'] = $this->sequenceStates($state['tables']);
|
||||||
if (!$complete) {
|
$state['done'] = true;
|
||||||
$state['hash'] = base64_encode(serialize($hash));
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
$state['result'][$table] = ['blobs' => array_merge(...$state['segments']), 'rows' => $state['rows'],
|
} finally {
|
||||||
'bytes' => $state['bytes'], 'sha256' => hash_final($hash)];
|
$this->db->rollBack();
|
||||||
$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 {
|
} finally {
|
||||||
$this->db->rollBack();
|
$repo->releaseIndexCache();
|
||||||
}
|
}
|
||||||
$repo->flushPacks();
|
$repo->flushPacks();
|
||||||
return $state;
|
return $state;
|
||||||
@@ -256,7 +263,11 @@ final class DbDumper {
|
|||||||
} elseif (is_string($row[$i]) && !mb_check_encoding($row[$i], 'UTF-8')) {
|
} elseif (is_string($row[$i]) && !mb_check_encoding($row[$i], 'UTF-8')) {
|
||||||
// Keep invalid UTF-8 byte-exact instead of substituting characters.
|
// Keep invalid UTF-8 byte-exact instead of substituting characters.
|
||||||
$row[$i] = ['$b' => base64_encode($row[$i])];
|
$row[$i] = ['$b' => base64_encode($row[$i])];
|
||||||
} elseif (is_int($row[$i]) || is_float($row[$i]) || is_bool($row[$i])) {
|
} elseif (is_float($row[$i])) {
|
||||||
|
// var_export uses serialize_precision (shortest exact form), not the lossy
|
||||||
|
// 14-digit 'precision' of a (string) cast.
|
||||||
|
$row[$i] = var_export($row[$i], true);
|
||||||
|
} elseif (is_int($row[$i]) || is_bool($row[$i])) {
|
||||||
// Normalise: drivers differ in returning numbers as int or string.
|
// Normalise: drivers differ in returning numbers as int or string.
|
||||||
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
|
$row[$i] = (string)(is_bool($row[$i]) ? (int)$row[$i] : $row[$i]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,17 @@ final class RunMapper extends QBMapper {
|
|||||||
return $this->findEntities($qb);
|
return $this->findEntities($qb);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return list<Run> runs of $kind started at or after $since, newest first */
|
||||||
|
public function startedSince(int $targetId, string $kind, int $since): array {
|
||||||
|
$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()->gte('started_at', $qb->createNamedParameter($since, IQueryBuilder::PARAM_INT)))
|
||||||
|
->orderBy('id', 'DESC');
|
||||||
|
return $this->findEntities($qb);
|
||||||
|
}
|
||||||
|
|
||||||
public function lastFinished(int $targetId, string $kind): ?Run {
|
public function lastFinished(int $targetId, string $kind): ?Run {
|
||||||
$qb = $this->db->getQueryBuilder();
|
$qb = $this->db->getQueryBuilder();
|
||||||
$qb->select('*')->from($this->getTableName())
|
$qb->select('*')->from($this->getTableName())
|
||||||
|
|||||||
+74
-14
@@ -49,9 +49,11 @@ final class BackupRun {
|
|||||||
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'] === 'files') {
|
if ($state['phase'] === 'files') {
|
||||||
$walker = new TreeWalker($state['roots'], $state['exclude']);
|
|
||||||
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
|
||||||
$stats = $state['stats'];
|
$stats = $state['stats'];
|
||||||
|
$walker = new TreeWalker($state['roots'], $state['exclude'], function (string $logical) use (&$state, &$stats): void {
|
||||||
|
self::recordUnreadable($state, $stats, $logical);
|
||||||
|
});
|
||||||
|
$parent = new ParentCursor($repo, $state['parent'], $state['parentPos']);
|
||||||
$lines = [];
|
$lines = [];
|
||||||
$finishedWalk = true;
|
$finishedWalk = true;
|
||||||
|
|
||||||
@@ -68,15 +70,20 @@ final class BackupRun {
|
|||||||
$stats['reused']++;
|
$stats['reused']++;
|
||||||
} else {
|
} else {
|
||||||
$cur = $state['cur'];
|
$cur = $state['cur'];
|
||||||
if ($cur === null || $cur['path'] !== $logical || $cur['version'] !== $version) {
|
if ($cur !== null && $cur['path'] === $logical && $cur['version'] !== $version) {
|
||||||
if ($cur !== null && $cur['path'] === $logical) {
|
// Changed since the previous step: start over, unless it keeps changing.
|
||||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1; // changed since the previous step
|
if (self::countRetry($state, $stats, $logical)) {
|
||||||
|
$cur['version'] = $version;
|
||||||
|
} else {
|
||||||
|
$cur = null;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if ($cur === null || $cur['path'] !== $logical) {
|
||||||
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
$cur = ['path' => $logical, 'offset' => 0, 'blobs' => [], 'version' => $version];
|
||||||
}
|
}
|
||||||
$fh = fopen($path, 'rb');
|
$fh = @fopen($path, 'rb');
|
||||||
if ($fh === false) {
|
if ($fh === false) {
|
||||||
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
|
self::recordUnreadable($state, $stats, $logical);
|
||||||
$state['cur'] = null;
|
$state['cur'] = null;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -91,12 +98,17 @@ final class BackupRun {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
fclose($fh);
|
fclose($fh);
|
||||||
if (Repository::fileVersion($path) !== $cur['version']) {
|
$now = Repository::fileVersion($path);
|
||||||
// Changed while reading: read it again from the start (now or next step).
|
if ($now !== $cur['version']) {
|
||||||
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
if (!self::countRetry($state, $stats, $logical)) {
|
||||||
$state['cur'] = null;
|
// Changed while reading: read it again from the start (now or next step).
|
||||||
$finishedWalk = false;
|
$state['cur'] = null;
|
||||||
break;
|
$finishedWalk = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Keeps changing (a live database, VM image or log): keep what was read,
|
||||||
|
// like any copy of a file in use, and report it instead of retrying forever.
|
||||||
|
$cur['version'] = $now;
|
||||||
}
|
}
|
||||||
if (!$complete) {
|
if (!$complete) {
|
||||||
$state['cur'] = $cur;
|
$state['cur'] = $cur;
|
||||||
@@ -104,6 +116,7 @@ final class BackupRun {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
$blobs = $cur['blobs'];
|
$blobs = $cur['blobs'];
|
||||||
|
$size = $cur['offset'];
|
||||||
$state['cur'] = null;
|
$state['cur'] = null;
|
||||||
$stats['read']++;
|
$stats['read']++;
|
||||||
}
|
}
|
||||||
@@ -135,7 +148,8 @@ final class BackupRun {
|
|||||||
$segments = $state['segments'];
|
$segments = $state['segments'];
|
||||||
$repo->putSnapshot($state['snapshot'],
|
$repo->putSnapshot($state['snapshot'],
|
||||||
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
['time' => $state['started'], 'parent' => $state['parent'], 'label' => $state['label'],
|
||||||
'roots' => array_keys($state['roots']), 'stats' => $state['stats']] + $state['meta'],
|
'roots' => array_keys($state['roots']), 'stats' => $state['stats'],
|
||||||
|
'warnings' => self::warnings($state)] + $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);
|
||||||
@@ -146,6 +160,52 @@ final class BackupRun {
|
|||||||
return $state;
|
return $state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A file or directory that could not be read: it is missing from this snapshot. Remembered (first
|
||||||
|
* 1000, deduplicated across steps) so the run can report it instead of silently succeeding.
|
||||||
|
*/
|
||||||
|
private static function recordUnreadable(array &$state, array &$stats, string $logical): void {
|
||||||
|
if (isset($state['unreadable'][$logical])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (count($state['unreadable'] ?? []) < 1000) {
|
||||||
|
$state['unreadable'][$logical] = true;
|
||||||
|
}
|
||||||
|
$stats['unreadable'] = ($stats['unreadable'] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{unreadable: list<string>, changing: list<string>} examples for the report (first 50 each) */
|
||||||
|
public static function warnings(array $state): array {
|
||||||
|
return [
|
||||||
|
'unreadable' => array_slice(array_map('strval', array_keys($state['unreadable'] ?? [])), 0, 50),
|
||||||
|
'changing' => array_slice(array_map('strval', array_values($state['changing'] ?? [])), 0, 50),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Retries of one file that changes while it is read, before its last read is kept anyway. */
|
||||||
|
public const MAX_RETRIES = 3;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count a retry of $logical. Returns true once it has changed MAX_RETRIES times: the caller then
|
||||||
|
* keeps the data it read, and the file is listed in $state['changing'] (first 50) for the report.
|
||||||
|
*/
|
||||||
|
private static function countRetry(array &$state, array &$stats, string $logical): bool {
|
||||||
|
$stats['retried'] = ($stats['retried'] ?? 0) + 1;
|
||||||
|
$n = (($state['retryPath'] ?? null) === $logical ? (int)($state['retryCount'] ?? 0) : 0) + 1;
|
||||||
|
$state['retryPath'] = $logical;
|
||||||
|
$state['retryCount'] = $n;
|
||||||
|
if ($n < self::MAX_RETRIES) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if ($n === self::MAX_RETRIES) {
|
||||||
|
$stats['changing'] = ($stats['changing'] ?? 0) + 1;
|
||||||
|
if (count($state['changing'] ?? []) < 50) {
|
||||||
|
$state['changing'][] = $logical;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ final class TreeWalker {
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private array $roots,
|
private array $roots,
|
||||||
private array $exclude = [],
|
private array $exclude = [],
|
||||||
|
/** Called with the logical path of a directory that cannot be listed. */
|
||||||
|
private ?\Closure $onUnreadable = null,
|
||||||
) {
|
) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +59,9 @@ final class TreeWalker {
|
|||||||
}
|
}
|
||||||
$names = @scandir($abs);
|
$names = @scandir($abs);
|
||||||
if ($names === false) {
|
if ($names === false) {
|
||||||
|
if ($this->onUnreadable !== null) {
|
||||||
|
($this->onUnreadable)($logical === '' ? '/' : $logical);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$names = array_values(array_diff($names, ['.', '..']));
|
$names = array_values(array_diff($names, ['.', '..']));
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ final class Notifier implements INotifier {
|
|||||||
: $l->t('Backup to %s failed', [$p['target']]));
|
: $l->t('Backup to %s failed', [$p['target']]));
|
||||||
$notification->setParsedMessage((string)($p['error'] ?? ''));
|
$notification->setParsedMessage((string)($p['error'] ?? ''));
|
||||||
break;
|
break;
|
||||||
|
case 'run_warnings':
|
||||||
|
$notification->setParsedSubject($l->t('Backup to %1$s finished with warnings: %2$s unreadable, %3$s changed while being read', [$p['target'], $p['unreadable'], $p['changing']]));
|
||||||
|
$notification->setParsedMessage($l->t('Unreadable files and folders are missing from this backup (check their permissions). Files that kept changing were saved as they were while being read. Examples: %s', [$p['examples'] ?? '']));
|
||||||
|
break;
|
||||||
case 'quota_warning':
|
case 'quota_warning':
|
||||||
$notification->setParsedSubject($l->t('Backup location %1$s is at %2$s%% of its limit (%3$s of %4$s)', [$p['target'], $p['percent'], $p['used'], $p['limit']]));
|
$notification->setParsedSubject($l->t('Backup location %1$s is at %2$s%% of its limit (%3$s of %4$s)', [$p['target'], $p['percent'], $p['used'], $p['limit']]));
|
||||||
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
|
$notification->setParsedMessage($l->t('Backups stop when the limit is reached. Raise the limit, or let the retention policy remove older backups.'));
|
||||||
@@ -62,6 +66,8 @@ final class Notifier implements INotifier {
|
|||||||
'catalog_trusted' => $l->t('%1$s accepted an older state of backup location %2$s (catalog generation %3$s)', [$who, $p['target'] ?? '', $p['generation'] ?? '']),
|
'catalog_trusted' => $l->t('%1$s accepted an older state of backup location %2$s (catalog generation %3$s)', [$who, $p['target'] ?? '', $p['generation'] ?? '']),
|
||||||
'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
'slot_added' => $l->t('%1$s added backup passphrase slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
||||||
'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
'slot_replaced' => $l->t('%1$s set a new passphrase for backup key slot %2$s (%3$s); download and confirm the new recovery kit', [$who, $p['slot'] ?? '', $p['label'] ?? '']),
|
||||||
|
'user_export_retention_changed' => $l->t('%1$s changed how many exports per user are kept to %2$s', [$who, $p['keep'] ?? '']),
|
||||||
|
'user_exports_forgotten' => $l->t('%1$s removed %2$s per-user exports from %3$s', [$who, $p['count'] ?? '', $p['target'] ?? '']),
|
||||||
'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']),
|
'slot_removed' => $l->t('%1$s removed backup passphrase slot %2$s; download and confirm the new recovery kit', [$who, $p['slot'] ?? '']),
|
||||||
default => $l->t('NG Backup security event'),
|
default => $l->t('NG Backup security event'),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ final class BlobIndex {
|
|||||||
private array $pending = [];
|
private array $pending = [];
|
||||||
|
|
||||||
private ?IIndexCache $cache = null;
|
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 = '';
|
private string $repositoryId = '';
|
||||||
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
/** @var int index files downloaded by the last load() (for tests and diagnostics) */
|
||||||
public int $downloaded = 0;
|
public int $downloaded = 0;
|
||||||
@@ -40,6 +42,46 @@ final class BlobIndex {
|
|||||||
$this->repositoryId = $repositoryId;
|
$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,
|
* 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
|
* 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) ?? []);
|
$cached = $verify ? [] : ($this->cache?->all($this->repositoryId) ?? []);
|
||||||
$present = [];
|
$present = [];
|
||||||
$this->downloaded = 0;
|
$this->downloaded = 0;
|
||||||
foreach ($this->backend->list('index') as $path) {
|
foreach ($this->backend->listFiles('index') as $path) {
|
||||||
$packId = basename($path);
|
$packId = basename($path);
|
||||||
$present[$packId] = true;
|
$present[$packId] = true;
|
||||||
if (isset($cached[$packId])) {
|
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));
|
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);
|
$this->cachePut($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']]);
|
||||||
@@ -131,7 +173,7 @@ final class BlobIndex {
|
|||||||
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
/** Forget a pack: delete its index file and cache row. Blobs that now live elsewhere are kept. */
|
||||||
public function removePack(string $packId): void {
|
public function removePack(string $packId): void {
|
||||||
$this->backend->delete('index/' . $packId);
|
$this->backend->delete('index/' . $packId);
|
||||||
$this->cache?->remove($this->repositoryId, [$packId]);
|
$this->cacheRemove([$packId]);
|
||||||
foreach ($this->blobs as $id => $loc) {
|
foreach ($this->blobs as $id => $loc) {
|
||||||
if ($loc[0] === $packId) {
|
if ($loc[0] === $packId) {
|
||||||
unset($this->blobs[$id]);
|
unset($this->blobs[$id]);
|
||||||
|
|||||||
@@ -88,6 +88,11 @@ final class Catalog {
|
|||||||
/** Write the next generation after the in-memory state was changed. */
|
/** Write the next generation after the in-memory state was changed. */
|
||||||
public function write(): void {
|
public function write(): void {
|
||||||
$next = $this->gen + 1;
|
$next = $this->gen + 1;
|
||||||
|
// Two writers starting from the same generation would otherwise overwrite each other
|
||||||
|
// silently and break the chain for whichever server anchored the lost write.
|
||||||
|
if ($this->backend->exists($this->path($next))) {
|
||||||
|
throw new RepositoryException("Catalog generation $next was written by another process at the same time; try again");
|
||||||
|
}
|
||||||
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
|
$plain = json_encode(['repository' => $this->repositoryId, 'gen' => $next, 'time' => gmdate('c'), 'prev' => $this->hash,
|
||||||
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
|
'snapshots' => array_keys($this->snapshots), 'trash' => array_keys($this->trash),
|
||||||
'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR);
|
'userExports' => array_keys($this->userExports)], JSON_THROW_ON_ERROR);
|
||||||
@@ -123,7 +128,7 @@ final class Catalog {
|
|||||||
|
|
||||||
/** @return list<int> */
|
/** @return list<int> */
|
||||||
private function generations(): array {
|
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 {
|
private function path(int $gen): string {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads blobs from packs. Restores and deep verifies read the blobs of a snapshot in the order
|
||||||
|
* they were written, so the stream of the pack read last is kept open and read on from where it
|
||||||
|
* is: one request per pack instead of one per blob (a round trip each, and on most remote
|
||||||
|
* storages a new connection or file handle too). Anything else opens the pack at the offset,
|
||||||
|
* as a single read always did.
|
||||||
|
*
|
||||||
|
* The open stream must not overlap other requests on the location (SFTP cannot read and write
|
||||||
|
* on one connection at once): Repository reaches the location through a PackReaderGuard, which
|
||||||
|
* closes it first.
|
||||||
|
*/
|
||||||
|
final class PackReader {
|
||||||
|
/** A gap up to this size in the open stream is read past instead of opening the pack again. */
|
||||||
|
private const SKIP_MAX = 8 * 1048576;
|
||||||
|
private const CHUNK = 1048576;
|
||||||
|
|
||||||
|
/** @var resource|null */
|
||||||
|
private $stream = null;
|
||||||
|
private string $pack = '';
|
||||||
|
private int $pos = 0;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $backend,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function read(string $pack, int $offset, int $length): string {
|
||||||
|
if ($this->stream !== null && $this->pack === $pack && $offset >= $this->pos && $offset - $this->pos <= self::SKIP_MAX) {
|
||||||
|
try {
|
||||||
|
$this->skipTo($offset);
|
||||||
|
return $this->readExactly($length);
|
||||||
|
} catch (\Exception) {
|
||||||
|
// The open stream broke (e.g. a timeout); open the pack again below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->close();
|
||||||
|
$fh = $this->backend->get('packs/' . substr($pack, 0, 2) . '/' . $pack);
|
||||||
|
$this->stream = $fh;
|
||||||
|
$this->pack = $pack;
|
||||||
|
$this->pos = 0;
|
||||||
|
try {
|
||||||
|
if ($offset > 0 && (stream_get_meta_data($fh)['seekable'] ?? false) && fseek($fh, $offset) === 0) {
|
||||||
|
$this->pos = $offset;
|
||||||
|
}
|
||||||
|
$this->skipTo($offset); // a stream that cannot seek is read up to the offset
|
||||||
|
return $this->readExactly($length);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$this->close();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function close(): void {
|
||||||
|
$fh = $this->stream;
|
||||||
|
$this->stream = null;
|
||||||
|
if ($fh !== null) {
|
||||||
|
@fclose($fh);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function __destruct() {
|
||||||
|
$this->close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function skipTo(int $offset): void {
|
||||||
|
while ($this->pos < $offset) {
|
||||||
|
$this->readExactly(min($offset - $this->pos, self::CHUNK));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read $length bytes from the open stream; a short read closes it. */
|
||||||
|
private function readExactly(int $length): string {
|
||||||
|
$fh = $this->stream ?? throw new RepositoryException('No open pack stream');
|
||||||
|
$data = '';
|
||||||
|
while (strlen($data) < $length) {
|
||||||
|
$chunk = fread($fh, min($length - strlen($data), self::CHUNK));
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
$this->close();
|
||||||
|
throw new RepositoryException('Short read in pack ' . $this->pack);
|
||||||
|
}
|
||||||
|
$data .= $chunk;
|
||||||
|
}
|
||||||
|
$this->pos += $length;
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
|
||||||
|
/** The location as Repository sees it: closes the PackReader's open stream before every other request. */
|
||||||
|
final class PackReaderGuard implements IBackend {
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
private PackReader $reader,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $stream */
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->reader->close();
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(string $path): bool {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->exists($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function move(string $from, string $to): void {
|
||||||
|
$this->reader->close();
|
||||||
|
$this->inner->move($from, $to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function list(string $prefix): array {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->list($prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function listFiles(string $dir): array {
|
||||||
|
$this->reader->close();
|
||||||
|
return $this->inner->listFiles($dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(string $path): void {
|
||||||
|
$this->reader->close();
|
||||||
|
$this->inner->delete($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,15 @@ final class Repository {
|
|||||||
public const FORMAT = 1;
|
public const FORMAT = 1;
|
||||||
public const BLOB_SIZE = 4 * 1048576;
|
public const BLOB_SIZE = 4 * 1048576;
|
||||||
private const FLAG_DEFLATE = 1;
|
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 StreamCipher $cipher;
|
||||||
private BlobIndex $index;
|
private BlobIndex $index;
|
||||||
private ?PackWriter $runPacks = null;
|
private ?PackWriter $runPacks = null;
|
||||||
private ?int $maxBytes = null;
|
private ?int $maxBytes = null;
|
||||||
private Catalog $catalog;
|
private Catalog $catalog;
|
||||||
|
private PackReader $packReader;
|
||||||
|
|
||||||
private function __construct(
|
private function __construct(
|
||||||
private IBackend $backend,
|
private IBackend $backend,
|
||||||
@@ -41,7 +44,9 @@ final class Repository {
|
|||||||
private string $repositoryId,
|
private string $repositoryId,
|
||||||
) {
|
) {
|
||||||
$this->cipher = new StreamCipher($keys);
|
$this->cipher = new StreamCipher($keys);
|
||||||
$this->index = new BlobIndex($backend, $this->cipher);
|
$this->packReader = new PackReader($backend);
|
||||||
|
$this->backend = new PackReaderGuard($backend, $this->packReader);
|
||||||
|
$this->index = new BlobIndex($this->backend, $this->cipher);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
public static function init(IBackend $backend, #[\SensitiveParameter] string $passphrase): self {
|
||||||
@@ -90,8 +95,8 @@ final class Repository {
|
|||||||
private function loadCatalog(?ICatalogAnchor $anchor): void {
|
private function loadCatalog(?ICatalogAnchor $anchor): void {
|
||||||
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
|
$this->catalog = new Catalog($this->backend, $this->cipher, $this->repositoryId, $anchor);
|
||||||
$this->catalog->load(
|
$this->catalog->load(
|
||||||
fn () => array_map('basename', $this->backend->list('snapshots')),
|
fn () => array_map('basename', $this->backend->listFiles('snapshots')),
|
||||||
fn () => array_map('basename', $this->backend->list('trash/info')),
|
fn () => array_map('basename', $this->backend->listFiles('trash/info')),
|
||||||
fn () => $this->backend->list('users'));
|
fn () => $this->backend->list('users'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,8 +241,13 @@ final class Repository {
|
|||||||
unlink($tmp);
|
unlink($tmp);
|
||||||
throw new RepositoryException('Size mismatch restoring ' . $entry['p']);
|
throw new RepositoryException('Size mismatch restoring ' . $entry['p']);
|
||||||
}
|
}
|
||||||
rename($tmp, $dest);
|
if (!rename($tmp, $dest)) {
|
||||||
touch($dest, $entry['m']);
|
@unlink($tmp);
|
||||||
|
throw new RepositoryException('Cannot put the restored file in place: ' . $entry['p']);
|
||||||
|
}
|
||||||
|
if (!touch($dest, $entry['m'])) {
|
||||||
|
throw new RepositoryException('Cannot set the modification time of ' . $entry['p']);
|
||||||
|
}
|
||||||
$count++;
|
$count++;
|
||||||
}
|
}
|
||||||
return $count;
|
return $count;
|
||||||
@@ -255,6 +265,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 {
|
public function flushPacks(): void {
|
||||||
$this->runPacks?->flush();
|
$this->runPacks?->flush();
|
||||||
}
|
}
|
||||||
@@ -375,31 +394,61 @@ final class Repository {
|
|||||||
// must be decrypted and re-encrypted under the new path, not moved as raw bytes.
|
// must be decrypted and re-encrypted under the new path, not moved as raw bytes.
|
||||||
$dbInTrash = 'trash/' . $meta['db'];
|
$dbInTrash = 'trash/' . $meta['db'];
|
||||||
$this->putObject($dbInTrash, $this->getObject($meta['db']));
|
$this->putObject($dbInTrash, $this->getObject($meta['db']));
|
||||||
$this->backend->delete($meta['db']);
|
|
||||||
}
|
}
|
||||||
// The snapshot object is bound to its id (not its path), so it can be moved as raw bytes.
|
// Copy first, switch the catalog, delete the originals last: a crash at any point leaves
|
||||||
$this->backend->move('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId);
|
// either the old state or the new one plus unreferenced leftovers, never a catalog entry
|
||||||
|
// pointing at a missing object. The snapshot object is bound to its id (not its path),
|
||||||
|
// so it can be copied as raw bytes.
|
||||||
|
$this->copyRaw('snapshots/' . $snapshotId, 'trash/snapshots/' . $snapshotId);
|
||||||
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
$this->putObject('trash/info/' . $snapshotId, json_encode(['forgottenAt' => time(), 'by' => $by, 'db' => $meta['db'] ?? null,
|
||||||
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
'dbInTrash' => $dbInTrash, 'time' => $meta['time'] ?? null, 'label' => $meta['label'] ?? ''], JSON_THROW_ON_ERROR));
|
||||||
unset($this->catalog->snapshots[$snapshotId]);
|
unset($this->catalog->snapshots[$snapshotId]);
|
||||||
$this->catalog->trash[$snapshotId] = true;
|
$this->catalog->trash[$snapshotId] = true;
|
||||||
$this->catalog->write();
|
$this->catalog->write();
|
||||||
|
if ($dbInTrash !== null) {
|
||||||
|
$this->backend->delete($meta['db']);
|
||||||
|
}
|
||||||
|
$this->backend->delete('snapshots/' . $snapshotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Undo forget(): move a snapshot back from the trash. */
|
/** Undo forget(): move a snapshot back from the trash. */
|
||||||
public function untrash(string $snapshotId): void {
|
public function untrash(string $snapshotId): void {
|
||||||
$info = $this->trashInfo($snapshotId);
|
$info = $this->trashInfo($snapshotId);
|
||||||
if ($info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash'])) {
|
$restoreDb = $info['dbInTrash'] !== null && $this->backend->exists($info['dbInTrash']);
|
||||||
|
if ($restoreDb) {
|
||||||
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
$this->putObject($info['db'], $this->getObject($info['dbInTrash']));
|
||||||
$this->backend->delete($info['dbInTrash']);
|
|
||||||
}
|
}
|
||||||
$this->backend->move('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
|
// Same order as forget(): copy, switch the catalog, then delete the trash copies.
|
||||||
|
$this->copyRaw('trash/snapshots/' . $snapshotId, 'snapshots/' . $snapshotId);
|
||||||
$this->catalog->snapshots[$snapshotId] = true;
|
$this->catalog->snapshots[$snapshotId] = true;
|
||||||
unset($this->catalog->trash[$snapshotId]);
|
unset($this->catalog->trash[$snapshotId]);
|
||||||
$this->catalog->write();
|
$this->catalog->write();
|
||||||
|
if ($restoreDb) {
|
||||||
|
$this->backend->delete($info['dbInTrash']);
|
||||||
|
}
|
||||||
|
$this->backend->delete('trash/snapshots/' . $snapshotId);
|
||||||
$this->backend->delete('trash/info/' . $snapshotId);
|
$this->backend->delete('trash/info/' . $snapshotId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Copy an object as raw bytes, buffered locally (remote read streams cannot be uploaded directly everywhere). */
|
||||||
|
private function copyRaw(string $from, string $to): void {
|
||||||
|
$in = $this->backend->get($from);
|
||||||
|
$buffer = fopen('php://temp/maxmemory:' . (4 * 1048576), 'w+b');
|
||||||
|
if ($buffer === false) {
|
||||||
|
fclose($in);
|
||||||
|
throw new RepositoryException("Cannot buffer $from");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (stream_copy_to_stream($in, $buffer) === false) {
|
||||||
|
throw new RepositoryException("Cannot read $from");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
fclose($in);
|
||||||
|
}
|
||||||
|
rewind($buffer);
|
||||||
|
$this->backend->put($to, $buffer);
|
||||||
|
}
|
||||||
|
|
||||||
/** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */
|
/** Record a user_migration export's manifest in the catalog (rollback-protected like a snapshot). */
|
||||||
public function recordUserExport(string $manifestPath): void {
|
public function recordUserExport(string $manifestPath): void {
|
||||||
$this->catalog->userExports[$manifestPath] = true;
|
$this->catalog->userExports[$manifestPath] = true;
|
||||||
@@ -530,7 +579,10 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
foreach ($this->backend->list('users') as $path) {
|
// listUserExports() throws when a recorded export's manifest is missing: then stop, since
|
||||||
|
// freeing its blobs would turn a tampering or storage alarm into irreversible deletion.
|
||||||
|
$exports = array_unique(array_merge($this->listUserExports(), $this->backend->list('users')));
|
||||||
|
foreach ($exports as $path) {
|
||||||
foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) {
|
foreach (json_decode($this->getObject($path), true, 512, JSON_THROW_ON_ERROR)['entries'] as $e) {
|
||||||
foreach ($e['b'] ?? [] as $b) {
|
foreach ($e['b'] ?? [] as $b) {
|
||||||
$used[$b] = true;
|
$used[$b] = true;
|
||||||
@@ -594,7 +646,7 @@ final class Repository {
|
|||||||
* @return list<string>
|
* @return list<string>
|
||||||
*/
|
*/
|
||||||
public function listSnapshots(): array {
|
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);
|
$listed = array_keys($this->catalog->snapshots);
|
||||||
foreach ($listed as $id) {
|
foreach ($listed as $id) {
|
||||||
if (!isset($present[$id])) {
|
if (!isset($present[$id])) {
|
||||||
@@ -658,8 +710,13 @@ final class Repository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function loadBlob(string $id): string {
|
private function loadBlob(string $id): string {
|
||||||
[$pack, $offset, $length, $raw, $flags] = $this->index->get($id);
|
[$pack, $offset, $length] = $this->index->get($id);
|
||||||
$encrypted = $this->backend->getRange('packs/' . substr($pack, 0, 2) . '/' . $pack, $offset, $length);
|
return $this->decodeBlob($id, $this->packReader->read($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);
|
$payload = $this->cipher->decryptString($encrypted, 'blob:' . $id);
|
||||||
$data = ($flags & self::FLAG_DEFLATE) ? gzinflate($payload) : $payload;
|
$data = ($flags & self::FLAG_DEFLATE) ? gzinflate($payload) : $payload;
|
||||||
if ($data === false || strlen($data) !== $raw || $this->keys->blobId($data) !== $id) {
|
if ($data === false || strlen($data) !== $raw || $this->keys->blobId($data) !== $id) {
|
||||||
@@ -695,13 +752,13 @@ final class Repository {
|
|||||||
*
|
*
|
||||||
* @param ?callable $heartbeat called after every blob (shallow) or every blob read (deep);
|
* @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
|
* 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 {
|
public function verify(string $snapshotId, bool $deep = false, ?callable $heartbeat = null): array {
|
||||||
$heartbeat ??= static function (): void {
|
$heartbeat ??= static function (): void {
|
||||||
};
|
};
|
||||||
$this->index->load(true);
|
$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 = [];
|
$seen = [];
|
||||||
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
|
$packSeen = []; // pack id => exists on the backend; avoids one exists() call per blob
|
||||||
foreach ($this->streamEntries($snapshotId) as $entry) {
|
foreach ($this->streamEntries($snapshotId) as $entry) {
|
||||||
@@ -740,7 +797,7 @@ final class Repository {
|
|||||||
* Same checks as verify(), for a user export: every blob its manifest references must be in
|
* 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.
|
* 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 {
|
public function verifyUserExport(string $manifestPath, bool $deep = false, ?callable $heartbeat = null): array {
|
||||||
if (!$this->isRecordedUserExport($manifestPath)) {
|
if (!$this->isRecordedUserExport($manifestPath)) {
|
||||||
@@ -749,7 +806,7 @@ final class Repository {
|
|||||||
$heartbeat ??= static function (): void {
|
$heartbeat ??= static function (): void {
|
||||||
};
|
};
|
||||||
$this->index->load(true);
|
$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 = [];
|
$seen = [];
|
||||||
$packSeen = [];
|
$packSeen = [];
|
||||||
$manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
$manifest = json_decode($this->getObject($manifestPath), true, 512, JSON_THROW_ON_ERROR);
|
||||||
@@ -786,9 +843,26 @@ final class Repository {
|
|||||||
if (!$deep) {
|
if (!$deep) {
|
||||||
return;
|
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->packReader->read($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 {
|
try {
|
||||||
$stats['bytesChecked'] += strlen($this->loadBlob($id));
|
$stats['bytesChecked'] += strlen($this->decodeBlob($id, $encrypted));
|
||||||
} catch (\Throwable) {
|
} catch (\Exception) { // damage; an \Error is a bug and must not be reported as damage
|
||||||
$stats['failed'][] = $id;
|
$stats['failed'][] = $id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,13 @@ final class AlertService {
|
|||||||
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
|
['kind' => $run->getKind(), 'target' => $targetName, 'error' => mb_strimwidth((string)$run->getError(), 0, 300, '…')]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** A backup that finished but is missing unreadable files, or holds files that changed while read. */
|
||||||
|
public function runWarnings(Run $run, string $targetName, int $unreadable, int $changing, array $examples): void {
|
||||||
|
$this->notifyAdmins('run_warnings', 'run-' . $run->getId(), ['target' => $targetName,
|
||||||
|
'unreadable' => (string)$unreadable, 'changing' => (string)$changing,
|
||||||
|
'examples' => mb_strimwidth(implode(', ', array_merge($examples['unreadable'], $examples['changing'])), 0, 300, '…')]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remember how much is stored on a location (for the settings page) and warn administrators
|
* Remember how much is stored on a location (for the settings page) and warn administrators
|
||||||
* once a day when it is at 80% of its limit or more.
|
* once a day when it is at 80% of its limit or more.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use OCA\NgBackup\Db\Snapshot;
|
|||||||
use OCA\NgBackup\Db\SnapshotMapper;
|
use OCA\NgBackup\Db\SnapshotMapper;
|
||||||
use OCA\NgBackup\Db\Target;
|
use OCA\NgBackup\Db\Target;
|
||||||
use OCA\NgBackup\Job\BackupRun;
|
use OCA\NgBackup\Job\BackupRun;
|
||||||
|
use OCA\NgBackup\Repository\RepositoryException;
|
||||||
use OCP\App\IAppManager;
|
use OCP\App\IAppManager;
|
||||||
use OCP\IConfig;
|
use OCP\IConfig;
|
||||||
use OCP\IDBConnection;
|
use OCP\IDBConnection;
|
||||||
@@ -151,16 +152,25 @@ final class BackupService {
|
|||||||
// Catalog write under the catalog lock, from a repository opened inside it: a per-user
|
// Catalog write under the catalog lock, from a repository opened inside it: a per-user
|
||||||
// export or restore may have written the catalog since this step opened $repo.
|
// export or restore may have written the catalog since this step opened $repo.
|
||||||
try {
|
try {
|
||||||
$this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 30,
|
$this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300,
|
||||||
fn () => $this->targets->repository($target)->recordSnapshot((string)$state['files']['snapshot']));
|
fn () => $this->targets->repository($target)->recordSnapshot((string)$state['files']['snapshot']));
|
||||||
$state['files']['phase'] = 'done';
|
$state['files']['phase'] = 'done';
|
||||||
} catch (LockedException) {
|
} catch (LockedException|RepositoryException) {
|
||||||
// Another catalog write is in progress: the next step retries this commit.
|
// Another catalog write is in progress (or just raced us to the next
|
||||||
|
// generation): the next step retries this commit on the fresh catalog.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($state['files']['phase'] === 'done') {
|
if ($state['files']['phase'] === 'done') {
|
||||||
$this->recordSnapshot($run, $state['files']);
|
$this->recordSnapshot($run, $state['files']);
|
||||||
$this->alerts->storedBytes($target, $repo->storedBytes());
|
$this->alerts->storedBytes($target, $repo->storedBytes());
|
||||||
|
$stats = $state['files']['stats'];
|
||||||
|
if (($stats['unreadable'] ?? 0) > 0 || ($stats['changing'] ?? 0) > 0) {
|
||||||
|
$warnings = BackupRun::warnings($state['files']);
|
||||||
|
$this->logger->warning(sprintf('NG Backup run %d to %s: %d unreadable (missing from the snapshot): %s; %d changed while being read: %s',
|
||||||
|
$run->getId(), $target->getName(), $stats['unreadable'] ?? 0, implode(', ', $warnings['unreadable']),
|
||||||
|
$stats['changing'] ?? 0, implode(', ', $warnings['changing'])));
|
||||||
|
$this->alerts->runWarnings($run, $target->getName(), (int)($stats['unreadable'] ?? 0), (int)($stats['changing'] ?? 0), $warnings);
|
||||||
|
}
|
||||||
$run->setStatus(Run::DONE);
|
$run->setStatus(Run::DONE);
|
||||||
$run->setPhase('done');
|
$run->setPhase('done');
|
||||||
$run->setFinishedAt(time());
|
$run->setFinishedAt(time());
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ final class RestoreFullService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{dbReport:array, dataFiles:int, configKeys:int} */
|
/** @return array{dbReport:array, dataFiles:int, configKeys:int, dbConsistent:bool} */
|
||||||
public function restoreFull(Target $target, string $snapshotId): array {
|
public function restoreFull(Target $target, string $snapshotId): array {
|
||||||
$check = $this->checkCompatibility($target, $snapshotId);
|
$check = $this->checkCompatibility($target, $snapshotId);
|
||||||
if (!$check['ok']) {
|
if (!$check['ok']) {
|
||||||
@@ -76,6 +76,7 @@ final class RestoreFullService {
|
|||||||
$meta = $repo->snapshotMeta($snapshotId);
|
$meta = $repo->snapshotMeta($snapshotId);
|
||||||
|
|
||||||
$dbReport = null;
|
$dbReport = null;
|
||||||
|
$manifest = [];
|
||||||
if (isset($meta['db'])) {
|
if (isset($meta['db'])) {
|
||||||
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
|
$manifest = json_decode($repo->getObject($meta['db']), true, 512, JSON_THROW_ON_ERROR);
|
||||||
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
|
$prefix = $this->config->getSystemValueString('dbtableprefix', 'oc_');
|
||||||
@@ -124,7 +125,8 @@ final class RestoreFullService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig)];
|
return ['dbReport' => $dbReport ?? ['skipped' => [], 'restored' => []], 'dataFiles' => $dataFiles, 'configKeys' => count($backupConfig),
|
||||||
|
'dbConsistent' => (bool)($manifest['consistent'] ?? true)];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ final class UserBackupService {
|
|||||||
|
|
||||||
private function withCatalogLock(Target $target, callable $fn): mixed {
|
private function withCatalogLock(Target $target, callable $fn): mixed {
|
||||||
try {
|
try {
|
||||||
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 30, $fn);
|
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, $fn);
|
||||||
} catch (LockedException) {
|
} catch (LockedException) {
|
||||||
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
|
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ final class UserRestoreService {
|
|||||||
|
|
||||||
private function withCatalogLock(Target $target, callable $fn): mixed {
|
private function withCatalogLock(Target $target, callable $fn): mixed {
|
||||||
try {
|
try {
|
||||||
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 30, $fn);
|
return $this->leases->with(PruneService::catalogLockKey($target), LeaseService::EXCLUSIVE, 300, $fn);
|
||||||
} catch (LockedException) {
|
} catch (LockedException) {
|
||||||
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
|
throw new \RuntimeException('Another catalog write for ' . $target->getName() . ' is in progress; try again');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
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 $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 {
|
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));
|
return $this->run($target, $deep, fn (\OCA\NgBackup\Repository\Repository $repo, callable $heartbeat): array => $repo->verifyUserExport($manifestPath, $deep, $heartbeat));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,14 +224,9 @@
|
|||||||
<MissingParamType>
|
<MissingParamType>
|
||||||
<code><![CDATA[$fh]]></code>
|
<code><![CDATA[$fh]]></code>
|
||||||
</MissingParamType>
|
</MissingParamType>
|
||||||
<PossiblyNullIterator>
|
|
||||||
<code><![CDATA[$segments]]></code>
|
|
||||||
</PossiblyNullIterator>
|
|
||||||
<PossiblyUndefinedArrayOffset>
|
<PossiblyUndefinedArrayOffset>
|
||||||
<code><![CDATA[$mtime]]></code>
|
<code><![CDATA[$mtime]]></code>
|
||||||
<code><![CDATA[$state['cur']]]></code>
|
|
||||||
<code><![CDATA[$state['phase']]]></code>
|
<code><![CDATA[$state['phase']]]></code>
|
||||||
<code><![CDATA[$state['segments']]]></code>
|
|
||||||
<code><![CDATA[$state['stats']]]></code>
|
<code><![CDATA[$state['stats']]]></code>
|
||||||
</PossiblyUndefinedArrayOffset>
|
</PossiblyUndefinedArrayOffset>
|
||||||
</file>
|
</file>
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 221 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 226 KiB |
@@ -82,6 +82,7 @@ onBeforeUnmount(() => clearTimeout(timer))
|
|||||||
.ngb-tabs {
|
.ngb-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
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>
|
</style>
|
||||||
|
|||||||
+27
-24
@@ -30,30 +30,32 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<table v-if="status.runs.length" class="ngb-table">
|
<div v-if="status.runs.length" class="ngb-table-wrap">
|
||||||
<thead>
|
<table class="ngb-table">
|
||||||
<tr>
|
<thead>
|
||||||
<th>{{ t('ng_backup', 'Started') }}</th>
|
<tr>
|
||||||
<th>{{ t('ng_backup', 'Location') }}</th>
|
<th>{{ t('ng_backup', 'Started') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Kind') }}</th>
|
<th>{{ t('ng_backup', 'Location') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
<th>{{ t('ng_backup', 'Kind') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Status') }}</th>
|
<th>{{ t('ng_backup', 'Restore point') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Files') }}</th>
|
<th>{{ t('ng_backup', 'Status') }}</th>
|
||||||
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
<th>{{ t('ng_backup', 'Files') }}</th>
|
||||||
</tr>
|
<th>{{ t('ng_backup', 'Uploaded') }}</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody>
|
</thead>
|
||||||
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
<tbody>
|
||||||
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
<tr v-for="run in status.runs" :key="run.id" :title="run.error || ''">
|
||||||
<td>{{ targetName(run.targetId) }}</td>
|
<td>{{ new Date(run.startedAt * 1000).toLocaleString() }}</td>
|
||||||
<td>{{ run.kind }}</td>
|
<td>{{ targetName(run.targetId) }}</td>
|
||||||
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
<td>{{ run.kind }}</td>
|
||||||
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
<td>{{ run.snapshot ? new Date(run.snapshot.createdAt * 1000).toLocaleString() + (run.snapshot.label ? ' – ' + run.snapshot.label : '') : '' }}</td>
|
||||||
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
<td :class="'ngb-' + run.status">{{ run.status }}{{ run.status === 'running' ? ' (' + run.phase + ')' : '' }}{{ run.error ? ': ' + run.error : '' }}</td>
|
||||||
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
<td>{{ run.stats?.files?.files ?? run.stats?.restored ?? '' }}</td>
|
||||||
</tr>
|
<td>{{ run.stats?.files ? formatSize(run.stats.files.uploaded) : '' }}</td>
|
||||||
</tbody>
|
</tr>
|
||||||
</table>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</NcSettingsSection>
|
</NcSettingsSection>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -126,6 +128,7 @@ async function saveSchedule() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
.ngb-row { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
.ngb-time { max-width: 260px; }
|
.ngb-time { max-width: 260px; }
|
||||||
|
.ngb-table-wrap { overflow-x: auto; }
|
||||||
.ngb-table { width: 100%; border-collapse: collapse; }
|
.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-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); }
|
.ngb-failed { color: var(--color-error-text); }
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
|
<td>{{ t('ng_backup', 'Slot {n}', { n: slot.slot }) }}</td>
|
||||||
<td><strong>{{ slot.label }}</strong></td>
|
<td><strong>{{ slot.label }}</strong></td>
|
||||||
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
|
<td class="ngb-muted">{{ slot.created ? new Date(slot.created * 1000).toLocaleDateString() : '' }}</td>
|
||||||
<td>
|
<td class="ngb-actions">
|
||||||
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
|
<NcButton variant="tertiary" @click="openSlotForm(slot)">{{ t('ng_backup', 'New passphrase') }}</NcButton>
|
||||||
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
<NcButton v-if="status.key.slots.length > 1" variant="tertiary" @click="removeSlot(slot)">{{ t('ng_backup', 'Remove') }}</NcButton>
|
||||||
</td>
|
</td>
|
||||||
@@ -162,5 +162,6 @@ const confirm = () => guarded(async () => {
|
|||||||
.ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; }
|
.ngb-form, .ngb-confirm { display: flex; flex-direction: column; gap: 12px; max-width: 600px; margin: 8px 0; }
|
||||||
.ngb-row { display: flex; gap: 8px; }
|
.ngb-row { display: flex; gap: 8px; }
|
||||||
.ngb-table td { padding: 2px 12px 2px 0; }
|
.ngb-table td { padding: 2px 12px 2px 0; }
|
||||||
|
.ngb-actions { display: flex; gap: 4px; }
|
||||||
h3 { margin-top: 16px; font-weight: bold; }
|
h3 { margin-top: 16px; font-weight: bold; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ async function add() {
|
|||||||
busy.value = true
|
busy.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
|
await confirmPassword()
|
||||||
await api.addTarget({ name: form.name, backend: backend.value.id, auth: auth.value.id, options: form.options, path: form.path,
|
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) })
|
maxGb: form.maxGb === '' ? null : Number(form.maxGb) })
|
||||||
adding.value = false
|
adding.value = false
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
|
:description="t('ng_backup', 'Pick a restore point and a user folder or file. Restores run in the background and go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.')">
|
||||||
<div class="ngb-row">
|
<div class="ngb-row">
|
||||||
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
|
<NcSelect v-model="target" :options="status.targets" label="name" :input-label="t('ng_backup', 'Location')" :clearable="false" />
|
||||||
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" label="displayLabel" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
|
<NcSelect v-if="target" v-model="snapshot" :options="snapshots" label="displayLabel" class="ngb-snapshot" :input-label="t('ng_backup', 'Restore point')" :clearable="false" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="snapshot">
|
<template v-if="snapshot">
|
||||||
@@ -69,7 +69,7 @@ const busy = ref(false)
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const message = ref('')
|
const message = ref('')
|
||||||
const modes = [
|
const modes = [
|
||||||
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored <date>" (nothing existing is changed)') },
|
{ id: 'new-folder', label: t('ng_backup', 'Into a new folder "Restored {date}" (nothing existing is changed)', { date: 'YYYY-MM-DD HH.MM' }) },
|
||||||
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
|
{ id: 'merge', label: t('ng_backup', 'Into the original place, overwrite (old content kept as a version)') },
|
||||||
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
{ id: 'replace', label: t('ng_backup', 'Into the original place, overwrite and move files that are not in the backup to the trash') },
|
||||||
]
|
]
|
||||||
@@ -124,6 +124,7 @@ async function restore() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
.ngb-row { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.ngb-row :deep(.ngb-snapshot) { min-width: min(360px, 100%); }
|
||||||
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
|
.ngb-crumbs { display: flex; align-items: center; flex-wrap: wrap; }
|
||||||
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
|
.ngb-entries li { display: flex; align-items: center; gap: 12px; min-height: 36px; }
|
||||||
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
|
.ngb-entries li.selected { background: var(--color-primary-element-light); border-radius: var(--border-radius); }
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Backend;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\SftpReadStream;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SftpReadStream against a fake of the phpseclib connection: an SFTP server that answers READ
|
||||||
|
* requests from in-memory files, optionally capped. Every answer must be asked for by its request
|
||||||
|
* id, as phpseclib matches them: a server may answer out of order.
|
||||||
|
*/
|
||||||
|
class SftpReadStreamTest extends TestCase {
|
||||||
|
protected function setUp(): void {
|
||||||
|
SftpReadStream::reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadsAWholeFileWithSeveralRequestsInFlight(): void {
|
||||||
|
$data = random_bytes(5 * 1048576 + 12345);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/repo/packs/ab/abcd' => $data]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'repo/packs/ab/abcd');
|
||||||
|
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
$this->assertTrue(feof($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(SftpReadStream::DEPTH, $sftp->maxInFlight);
|
||||||
|
$this->assertSame(0, $sftp->pending(), 'nothing may be left on the connection after close');
|
||||||
|
$this->assertSame([], $sftp->openHandles);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerThatCapsAReadStillGivesEveryByteInOrder(): void {
|
||||||
|
$data = random_bytes(3 * 1048576 + 7);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], cap: 100000);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
|
||||||
|
// The cap is learned: a next stream asks for no more than the server gives.
|
||||||
|
$sftp->reads = [];
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([100000], array_values(array_unique($sftp->reads)));
|
||||||
|
|
||||||
|
// Only for that connection.
|
||||||
|
$other = new FakeSftpConnection(['/home/f' => $data]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($other), 'f');
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(261120, max($other->reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testATinyAnswerDoesNotShrinkTheReadsBelowWhatEveryServerSupports(): void {
|
||||||
|
$data = random_bytes(1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], cap: 1000);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
$this->assertSame($data, stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(32768, min($sftp->reads));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSeekingReadsFromTheNewOffset(): void {
|
||||||
|
$data = random_bytes(3 * 1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
|
||||||
|
$this->assertSame(0, fseek($fh, 2000000));
|
||||||
|
$this->assertSame(2000000, ftell($fh));
|
||||||
|
$this->assertSame(substr($data, 2000000, 1000), fread($fh, 1000));
|
||||||
|
$this->assertSame(0, fseek($fh, 10)); // backwards
|
||||||
|
$this->assertSame(substr($data, 10, 70000), $this->readExactly($fh, 70000));
|
||||||
|
$this->assertSame(0, fseek($fh, 100, SEEK_CUR)); // within what is already buffered
|
||||||
|
$this->assertSame(substr($data, 70110, 5000), $this->readExactly($fh, 5000));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testASmallReadDoesNotFetchTheWholePipelineAhead(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(10 * 1048576)]);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
fread($fh, 100);
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertCount(1, $sftp->reads, 'the first request goes alone, the pipeline grows while reading on');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAnEmptyFileIsAtEndAtOnce(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => '']);
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage($sftp), 'f');
|
||||||
|
$this->assertSame('', stream_get_contents($fh));
|
||||||
|
$this->assertTrue(feof($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAMissingFileCannotBeOpened(): void {
|
||||||
|
$sftp = new FakeSftpConnection([]);
|
||||||
|
$this->assertFalse(SftpReadStream::open(new FakeSftpStorage($sftp), 'nope'));
|
||||||
|
$this->assertSame(0, $sftp->pending());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerErrorGoesOnWithTheStoragesOwnStreamFromTheSamePosition(): void {
|
||||||
|
$data = random_bytes(3 * 1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], failAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
$this->assertSame(['f'], $storage->fopens);
|
||||||
|
$this->assertSame(strlen($data), ftell($fh));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(0, $sftp->pending(), 'the connection is clean for the storage\'s own stream');
|
||||||
|
$this->assertSame([], $sftp->openHandles, 'the failed handle is closed');
|
||||||
|
|
||||||
|
// One failure is no reason to give up on the fast reads.
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertIsResource($fh);
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOnlyFailuresInARowSwitchTheFastReadsOff(): void {
|
||||||
|
$data = random_bytes(2 * 1048576);
|
||||||
|
$failing = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => $data], failAt: 1048576));
|
||||||
|
$working = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => $data]));
|
||||||
|
for ($i = 1; $i < SftpReadStream::MAX_FAILURES; $i++) {
|
||||||
|
$fh = SftpReadStream::open($failing, 'f');
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
$fh = SftpReadStream::open($working, 'f'); // a stream that works resets the count
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
fclose($fh);
|
||||||
|
for ($i = 1; $i <= SftpReadStream::MAX_FAILURES; $i++) {
|
||||||
|
$fh = SftpReadStream::open($failing, 'f');
|
||||||
|
$this->assertIsResource($fh, "failure $i");
|
||||||
|
$this->assertSame($data, $this->readAll($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
$this->assertNull(SftpReadStream::open($working, 'f'));
|
||||||
|
$this->assertSame(SftpReadStream::MAX_FAILURES * 2 - 1, count($failing->fopens), 'every failed stream still gave all its data');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAServerErrorAfterASeekGoesOnFromTheSeekedPosition(): void {
|
||||||
|
$data = random_bytes(3 * 1048576);
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => $data], failAt: 2500000);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertSame(0, fseek($fh, 2400000));
|
||||||
|
$this->assertSame(substr($data, 2400000), $this->readAll($fh));
|
||||||
|
$this->assertSame(0, fseek($fh, 100)); // seeks go to the storage's stream now
|
||||||
|
$this->assertSame(substr($data, 100, 1000), $this->readExactly($fh, 1000));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionErrorDoesNotFallBackOnTheSameConnection(): void {
|
||||||
|
// After an exception, answers may still be on their way; files_external's stream would
|
||||||
|
// take them for its own.
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], throwAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testALostConnectionDoesNotFallBackEither(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], loseAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWhenTheStoragesOwnStreamFailsTooTheReadFails(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], failAt: 1048576);
|
||||||
|
$storage = new FakeSftpStorage($sftp, fopenFails: true);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame(['f'], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionErrorWhileOpeningIsAnErrorNotAMissingFile(): void {
|
||||||
|
// The OPEN request went out: the storage's own stream must not be tried on this connection.
|
||||||
|
$storage = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'], throwAt: -1));
|
||||||
|
try {
|
||||||
|
SftpReadStream::open($storage, 'f');
|
||||||
|
$this->fail('expected a BackendException');
|
||||||
|
} catch (BackendException $e) {
|
||||||
|
$this->assertStringContainsString('connection', $e->getMessage());
|
||||||
|
}
|
||||||
|
$lost = new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'], loseAt: -1));
|
||||||
|
$this->expectException(BackendException::class);
|
||||||
|
SftpReadStream::open($lost, 'f');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionErrorWhileOpeningDoesNotStopTheNextFile(): void {
|
||||||
|
try {
|
||||||
|
SftpReadStream::open(new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'], throwAt: -1)), 'f');
|
||||||
|
} catch (BackendException) {
|
||||||
|
}
|
||||||
|
$fh = SftpReadStream::open(new FakeSftpStorage(new FakeSftpConnection(['/home/f' => 'x'])), 'f');
|
||||||
|
$this->assertSame('x', stream_get_contents($fh));
|
||||||
|
fclose($fh);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAFailedCloseDoesNotFallBackOnTheSameConnection(): void {
|
||||||
|
$sftp = new FakeSftpConnection(['/home/f' => random_bytes(3 * 1048576)], failAt: 1048576, closeFails: true);
|
||||||
|
$storage = new FakeSftpStorage($sftp);
|
||||||
|
$fh = SftpReadStream::open($storage, 'f');
|
||||||
|
$this->assertLessThan(3 * 1048576, strlen($this->readAll($fh)));
|
||||||
|
fclose($fh);
|
||||||
|
$this->assertSame([], $storage->fopens);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAStorageThatThrowsIsNotUsed(): void {
|
||||||
|
$storage = new class {
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
throw new \RuntimeException('Login failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAConnectionWithoutTheNeededMethodsIsNotUsed(): void {
|
||||||
|
$storage = new class {
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
return new \stdClass();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
$this->assertNull(SftpReadStream::open(new \stdClass(), 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPhpseclib3ConnectionIsNotUsed(): void {
|
||||||
|
// phpseclib 3 has no _-prefixed methods; its packet_type and packet methods are private.
|
||||||
|
$v3 = new class {
|
||||||
|
private int $packet_type = -1;
|
||||||
|
|
||||||
|
private function _send_sftp_packet(): bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _get_sftp_packet(): string {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _realpath(string $path): string {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function realpath(string $path): string {
|
||||||
|
return $path . (string)$this->packet_type . $this->_send_sftp_packet() . $this->_get_sftp_packet();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$storage = new class($v3) {
|
||||||
|
public function __construct(
|
||||||
|
private object $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPrivatePacketTypeIsNotUsed(): void {
|
||||||
|
$conn = new class {
|
||||||
|
private int $packet_type = -1;
|
||||||
|
|
||||||
|
public function _send_sftp_packet(): bool {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _get_sftp_packet(): string {
|
||||||
|
return (string)$this->packet_type;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _realpath(string $path): string {
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$storage = new class($conn) {
|
||||||
|
public function __construct(
|
||||||
|
private object $connection,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): object {
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$this->assertNull(SftpReadStream::open($storage, 'f'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $fh */
|
||||||
|
private function readAll($fh): string {
|
||||||
|
$data = '';
|
||||||
|
while (!feof($fh)) {
|
||||||
|
$chunk = fread($fh, 65536);
|
||||||
|
if ($chunk === false || $chunk === '') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$data .= $chunk;
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param resource $fh */
|
||||||
|
private function readExactly($fh, int $length): string {
|
||||||
|
$data = '';
|
||||||
|
while (strlen($data) < $length && !feof($fh)) {
|
||||||
|
$data .= fread($fh, $length - strlen($data));
|
||||||
|
}
|
||||||
|
return $data;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class FakeSftpStorage {
|
||||||
|
/** @var list<string> paths opened through the storage's own stream */
|
||||||
|
public array $fopens = [];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private FakeSftpConnection $connection,
|
||||||
|
private bool $fopenFails = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** files_external's own read stream: here the file from memory, seekable. */
|
||||||
|
public function fopen(string $path, string $mode) {
|
||||||
|
$this->fopens[] = $path;
|
||||||
|
$data = $this->connection->file('/home/' . $path);
|
||||||
|
if ($this->fopenFails || $data === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$fh = fopen('php://memory', 'r+');
|
||||||
|
fwrite($fh, $data);
|
||||||
|
rewind($fh);
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRoot(): string {
|
||||||
|
return '/home/';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getConnection(): FakeSftpConnection {
|
||||||
|
return $this->connection;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The part of phpseclib\Net\SFTP (2.x) that SftpReadStream uses. */
|
||||||
|
final class FakeSftpConnection {
|
||||||
|
public int $packet_type = -1;
|
||||||
|
public int $maxInFlight = 0;
|
||||||
|
/** @var list<int> lengths asked for by READ requests */
|
||||||
|
public array $reads = [];
|
||||||
|
/** @var array<string, string> */
|
||||||
|
public array $openHandles = [];
|
||||||
|
/** @var array<int, array{int, string}> answers by request id, in the order the server sent them */
|
||||||
|
private array $answers = [];
|
||||||
|
private int $handles = 0;
|
||||||
|
|
||||||
|
/** @param array<string, string> $files */
|
||||||
|
public function __construct(
|
||||||
|
private array $files,
|
||||||
|
private int $cap = 261120,
|
||||||
|
private ?int $failAt = null,
|
||||||
|
/** READ at or past this offset throws; -1: OPEN throws */
|
||||||
|
private ?int $throwAt = null,
|
||||||
|
/** READ at or past this offset finds the connection gone (phpseclib returns false); -1: OPEN */
|
||||||
|
private ?int $loseAt = null,
|
||||||
|
private bool $closeFails = false,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(string $path): ?string {
|
||||||
|
return $this->files[$path] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pending(): int {
|
||||||
|
return count($this->answers);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _realpath(string $path): string {
|
||||||
|
return '/' . trim(preg_replace('#/+#', '/', $path), '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _send_sftp_packet(int $type, string $data, int $id = 1): bool {
|
||||||
|
switch ($type) {
|
||||||
|
case 3: // OPEN
|
||||||
|
if ($this->throwAt === -1) {
|
||||||
|
throw new \RuntimeException('Connection closed by server');
|
||||||
|
}
|
||||||
|
if ($this->loseAt === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$path = substr($data, 4, unpack('N', $data)[1]);
|
||||||
|
if (!isset($this->files[$path])) {
|
||||||
|
$this->answers[$id] = [101, pack('N', 2)];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
$handle = 'h' . ++$this->handles;
|
||||||
|
$this->openHandles[$handle] = $path;
|
||||||
|
$this->answers[$id] = [102, pack('Na*', strlen($handle), $handle)];
|
||||||
|
break;
|
||||||
|
case 4: // CLOSE
|
||||||
|
if ($this->closeFails) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$handle = substr($data, 4, unpack('N', $data)[1]);
|
||||||
|
unset($this->openHandles[$handle]);
|
||||||
|
$this->answers[$id] = [101, pack('N', 0)];
|
||||||
|
break;
|
||||||
|
case 5: // READ
|
||||||
|
$len = unpack('N', $data)[1];
|
||||||
|
$handle = substr($data, 4, $len);
|
||||||
|
['hi' => $hi, 'lo' => $lo, 'n' => $n] = unpack('Nhi/Nlo/Nn', substr($data, 4 + $len));
|
||||||
|
$offset = $hi * 4294967296 + $lo;
|
||||||
|
$this->reads[] = $n;
|
||||||
|
if ($this->throwAt !== null && $this->throwAt >= 0 && $offset >= $this->throwAt) {
|
||||||
|
throw new \RuntimeException('Connection closed by server');
|
||||||
|
}
|
||||||
|
if ($this->loseAt !== null && $this->loseAt >= 0 && $offset >= $this->loseAt) {
|
||||||
|
$this->answers = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
$file = $this->files[$this->openHandles[$handle]];
|
||||||
|
if ($this->failAt !== null && $offset >= $this->failAt) {
|
||||||
|
$this->answers[$id] = [101, pack('N', 4)]; // FX_FAILURE
|
||||||
|
} elseif ($offset >= strlen($file)) {
|
||||||
|
$this->answers[$id] = [101, pack('N', 1)]; // FX_EOF
|
||||||
|
} else {
|
||||||
|
$chunk = substr($file, $offset, min($n, $this->cap));
|
||||||
|
$this->answers[$id] = [103, pack('Na*', strlen($chunk), $chunk)];
|
||||||
|
}
|
||||||
|
$this->maxInFlight = max($this->maxInFlight, count($this->answers));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new \LogicException("unexpected packet type $type");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function _get_sftp_packet(?int $id = null): string|false {
|
||||||
|
if ($this->answers === []) {
|
||||||
|
throw new \LogicException('waiting for an answer that will never come');
|
||||||
|
}
|
||||||
|
if ($id === null || !isset($this->answers[$id])) {
|
||||||
|
throw new \LogicException("no answer for request $id");
|
||||||
|
}
|
||||||
|
[$this->packet_type, $payload] = $this->answers[$id];
|
||||||
|
unset($this->answers[$id]);
|
||||||
|
return $payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
// SPDX-FileCopyrightText: 2026 Dillard Blom
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Job\BackupRun;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Repository\RepositoryException;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** Data-safety cases found in the pre-beta review. */
|
||||||
|
class CrashSafetyTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
public function testListingKeepsNamesThatOnlyLookLikeTemporaryFiles(): void {
|
||||||
|
$root = $this->tempDir();
|
||||||
|
$backend = new LocalBackend($root);
|
||||||
|
mkdir("$root/users/john.part-time", 0700, true);
|
||||||
|
file_put_contents("$root/users/john.part-time/20261006T120000Z-aaaa0001", 'x');
|
||||||
|
file_put_contents("$root/users/upload.part-deadbeef", 'partial');
|
||||||
|
$this->assertSame(['users/john.part-time/20261006T120000Z-aaaa0001'], $backend->list('users'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPruneStopsWhenARecordedExportManifestIsMissing(): void {
|
||||||
|
$root = $this->tempDir();
|
||||||
|
$backend = new LocalBackend($root);
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
$repo = Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
$stats = [];
|
||||||
|
$blob = $repo->storeData(random_bytes(1000), $stats);
|
||||||
|
$repo->flushPacks();
|
||||||
|
$path = 'users/alice/20261006T120000Z-aaaa0001';
|
||||||
|
$repo->putObject($path, json_encode(['uid' => 'alice', 'entries' => ['f.txt' => ['t' => 'f', 'b' => [$blob], 's' => 1000]]]));
|
||||||
|
$repo->recordUserExport($path);
|
||||||
|
unlink("$root/$path");
|
||||||
|
|
||||||
|
$packsBefore = $backend->list('packs');
|
||||||
|
try {
|
||||||
|
Repository::openWithKey($backend, $keys)->prune();
|
||||||
|
$this->fail('prune must refuse while a recorded export is missing');
|
||||||
|
} catch (RepositoryException $e) {
|
||||||
|
$this->assertStringContainsString('missing on the location', $e->getMessage());
|
||||||
|
}
|
||||||
|
$this->assertSame($packsBefore, $backend->list('packs'), 'no pack was deleted');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForgetAndUntrashKeepTheSnapshotRestorable(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
file_put_contents("$src/a.txt", 'hello');
|
||||||
|
$root = $this->tempDir();
|
||||||
|
$backend = new LocalBackend($root);
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
$state = BackupRun::start($src);
|
||||||
|
while ($state['phase'] !== 'commit') {
|
||||||
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 5);
|
||||||
|
}
|
||||||
|
Repository::openWithKey($backend, $keys)->recordSnapshot($state['snapshot']);
|
||||||
|
$id = $state['snapshot'];
|
||||||
|
|
||||||
|
Repository::openWithKey($backend, $keys)->forget($id, 'test');
|
||||||
|
$repo = Repository::openWithKey($backend, $keys);
|
||||||
|
$this->assertSame([], $repo->listSnapshots());
|
||||||
|
$this->assertArrayHasKey($id, $repo->trash());
|
||||||
|
$this->assertFalse($backend->exists('snapshots/' . $id), 'original removed after the catalog switch');
|
||||||
|
|
||||||
|
$repo->untrash($id);
|
||||||
|
$repo = Repository::openWithKey($backend, $keys);
|
||||||
|
$this->assertSame([$id], $repo->listSnapshots());
|
||||||
|
$this->assertSame([], $repo->trash());
|
||||||
|
$target = $this->tempDir();
|
||||||
|
$repo->restore($id, $target);
|
||||||
|
$this->assertSame('hello', file_get_contents("$target/a.txt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFileThatKeepsChangingDoesNotStallTheRun(): void {
|
||||||
|
$src = $this->tempDir();
|
||||||
|
file_put_contents("$src/live.db", random_bytes(3 * Repository::BLOB_SIZE));
|
||||||
|
$root = $this->tempDir();
|
||||||
|
$backend = new LocalBackend($root);
|
||||||
|
$keys = KeyRing::generate();
|
||||||
|
Repository::initWithKey($backend, $keys, $keys->wrap('pw'));
|
||||||
|
|
||||||
|
$state = BackupRun::start($src);
|
||||||
|
$n = 0;
|
||||||
|
for ($i = 0; $i < 50 && $state['phase'] !== 'commit'; $i++) {
|
||||||
|
$state = BackupRun::step(Repository::openWithKey($backend, $keys), $state, microtime(true) + 0.001);
|
||||||
|
// Rewrite the file after every step: it never stays the same between two steps.
|
||||||
|
$fh = fopen("$src/live.db", 'r+b');
|
||||||
|
fwrite($fh, random_bytes(16));
|
||||||
|
fclose($fh);
|
||||||
|
touch("$src/live.db", time() + ++$n);
|
||||||
|
clearstatcache();
|
||||||
|
}
|
||||||
|
$this->assertSame('commit', $state['phase'], 'the run finishes instead of retrying forever');
|
||||||
|
$this->assertSame(1, $state['stats']['changing'] ?? 0);
|
||||||
|
$this->assertSame(['live.db'], BackupRun::warnings($state)['changing']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,4 +51,41 @@ class IndexCacheTest extends TestCase {
|
|||||||
$fourth->restore($r1['snapshot'], $target);
|
$fourth->restore($r1['snapshot'], $target);
|
||||||
$this->assertCount(12, self::hashTree($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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
|
use OCA\NgBackup\Repository\Repository;
|
||||||
|
use OCA\NgBackup\Tests\Unit\TempDirTrait;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** Restores and deep verifies read a pack as one stream instead of one request per blob. */
|
||||||
|
class PackReadTest extends TestCase {
|
||||||
|
use TempDirTrait;
|
||||||
|
|
||||||
|
private string $root;
|
||||||
|
private KeyRing $keys;
|
||||||
|
private string $src;
|
||||||
|
private string $snapshot;
|
||||||
|
|
||||||
|
protected function setUp(): void {
|
||||||
|
$this->root = $this->tempDir();
|
||||||
|
$this->keys = KeyRing::generate();
|
||||||
|
$this->src = $this->tempDir();
|
||||||
|
for ($i = 0; $i < 200; $i++) {
|
||||||
|
file_put_contents(sprintf('%s/f%03d', $this->src, $i), random_bytes(20000));
|
||||||
|
}
|
||||||
|
$backend = new LocalBackend($this->root);
|
||||||
|
$repo = Repository::initWithKey($backend, $this->keys, $this->keys->wrap('pw'));
|
||||||
|
$this->snapshot = $repo->backupDirectory($this->src)['snapshot'];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function open(PackOpenCountingBackend $backend): Repository {
|
||||||
|
return Repository::openWithKey($backend, $this->keys, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullRestoreOpensEachPackOnce(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(200, $this->open($backend)->restore($this->snapshot, $target));
|
||||||
|
|
||||||
|
for ($i = 0; $i < 200; $i++) {
|
||||||
|
$name = sprintf('f%03d', $i);
|
||||||
|
$this->assertSame(file_get_contents("$this->src/$name"), file_get_contents("$target/$name"));
|
||||||
|
}
|
||||||
|
// One pack: one stream instead of 200 requests.
|
||||||
|
$this->assertSame(1, $backend->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeepVerifyOpensEachPackOnce(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
|
||||||
|
$result = $this->open($backend)->verify($this->snapshot, deep: true);
|
||||||
|
|
||||||
|
$this->assertSame(200 * 20000, $result['bytesChecked']);
|
||||||
|
$this->assertSame(1, $backend->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadingBackwardsOpensThePackAgain(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
$repo = $this->open($backend);
|
||||||
|
|
||||||
|
$this->assertSame(1, $repo->restore($this->snapshot, $this->tempDir(), 'f150'));
|
||||||
|
$this->assertSame(1, $repo->restore($this->snapshot, $this->tempDir(), 'f050'));
|
||||||
|
|
||||||
|
$this->assertSame(2, $backend->packOpens());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThePackStreamIsClosedBeforeAnyOtherRequest(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root));
|
||||||
|
$repo = $this->open($backend);
|
||||||
|
$this->assertSame(1, $repo->restore($this->snapshot, $this->tempDir(), 'f010'));
|
||||||
|
$this->assertTrue($backend->packStreamOpen(), 'kept open for the next read');
|
||||||
|
|
||||||
|
$repo->putObject('objects/after-restore', 'data');
|
||||||
|
|
||||||
|
$this->assertSame([false], $backend->packStreamOpenAtPut);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAPackStreamThatCannotSeekIsReadUpToTheOffset(): void {
|
||||||
|
$backend = new PackOpenCountingBackend(new LocalBackend($this->root), $this->root);
|
||||||
|
$target = $this->tempDir();
|
||||||
|
|
||||||
|
$this->assertSame(1, $this->open($backend)->restore($this->snapshot, $target, 'f150'));
|
||||||
|
|
||||||
|
$this->assertSame(file_get_contents("$this->src/f150"), file_get_contents("$target/f150"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDeepVerifyOfATruncatedPackStillChecksTheBlobsBeforeTheDamage(): void {
|
||||||
|
$packs = (new LocalBackend($this->root))->list('packs');
|
||||||
|
$this->assertCount(1, $packs);
|
||||||
|
$pack = $this->root . '/' . $packs[0];
|
||||||
|
$fh = fopen($pack, 'r+b');
|
||||||
|
ftruncate($fh, intdiv(filesize($pack), 2));
|
||||||
|
fclose($fh);
|
||||||
|
|
||||||
|
$result = $this->open(new PackOpenCountingBackend(new LocalBackend($this->root)))->verify($this->snapshot, deep: true);
|
||||||
|
|
||||||
|
$this->assertGreaterThan(80 * 20000, $result['bytesChecked']);
|
||||||
|
$this->assertNotSame([], $result['unreadable']);
|
||||||
|
$this->assertSame([], $result['failed']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class PackOpenCountingBackend implements IBackend {
|
||||||
|
/** @var list<string> */
|
||||||
|
public array $gets = [];
|
||||||
|
/** @var list<bool> */
|
||||||
|
public array $packStreamOpenAtPut = [];
|
||||||
|
/** @var resource|null */
|
||||||
|
private $lastPackStream = null;
|
||||||
|
|
||||||
|
/** @param ?string $pipeFrom serve packs as pipes (streams that cannot seek) from this local repository root */
|
||||||
|
public function __construct(
|
||||||
|
private IBackend $inner,
|
||||||
|
private ?string $pipeFrom = null,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packStreamOpen(): bool {
|
||||||
|
return is_resource($this->lastPackStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function put(string $path, $stream): void {
|
||||||
|
$this->packStreamOpenAtPut[] = $this->packStreamOpen();
|
||||||
|
$this->inner->put($path, $stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function packOpens(): int {
|
||||||
|
return count(array_filter($this->gets, fn (string $p): bool => str_starts_with($p, 'packs/')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(string $path) {
|
||||||
|
$this->gets[] = $path;
|
||||||
|
if (!str_starts_with($path, 'packs/')) {
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
$fh = $this->pipeFrom !== null
|
||||||
|
? popen('cat ' . escapeshellarg($this->pipeFrom . '/' . $path), 'r')
|
||||||
|
: $this->inner->get($path);
|
||||||
|
$this->lastPackStream = $fh;
|
||||||
|
return $fh;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$this->gets[] = $path;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace OCA\NgBackup\Tests\Unit\Repository;
|
namespace OCA\NgBackup\Tests\Unit\Repository;
|
||||||
|
|
||||||
|
use OCA\NgBackup\Backend\BackendException;
|
||||||
|
use OCA\NgBackup\Backend\IBackend;
|
||||||
use OCA\NgBackup\Backend\LocalBackend;
|
use OCA\NgBackup\Backend\LocalBackend;
|
||||||
use OCA\NgBackup\Crypto\KeyRing;
|
use OCA\NgBackup\Crypto\KeyRing;
|
||||||
use OCA\NgBackup\Repository\Repository;
|
use OCA\NgBackup\Repository\Repository;
|
||||||
@@ -92,4 +94,90 @@ class VerifyTest extends TestCase {
|
|||||||
|
|
||||||
$this->assertNotSame([], $result['failed']);
|
$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 reading a pack throws for the first $failures reads. */
|
||||||
|
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) {
|
||||||
|
if (str_starts_with($path, 'packs/')) {
|
||||||
|
$this->fail();
|
||||||
|
}
|
||||||
|
return $this->inner->get($path);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getRange(string $path, int $offset, int $length): string {
|
||||||
|
$this->fail();
|
||||||
|
return $this->inner->getRange($path, $offset, $length);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function fail(): void {
|
||||||
|
if ($this->failures > 0) {
|
||||||
|
$this->failures--;
|
||||||
|
throw $this->error ?? new BackendException('connection reset');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
declare(strict_types=1);
|
||||||
|
|
||||||
require __DIR__ . '/../vendor/autoload.php';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ run_on ngb-frt-restore backup:target:add offsite local -a null::null -o datadir=
|
|||||||
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
|
run_on ngb-frt-restore backup:list offsite | grep -q "$SNAP" || fail "the origin's snapshot is not listed after connecting the location"
|
||||||
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
ok "recovery kit imported, existing repository adopted, snapshot visible"
|
||||||
|
|
||||||
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" >/dev/null || fail "restore:full"
|
run_on ngb-frt-restore backup:restore:full offsite "$SNAP" --no-interaction >/dev/null || fail "restore:full"
|
||||||
ok "backup:restore:full completed"
|
ok "backup:restore:full completed"
|
||||||
|
|
||||||
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
# Everything below must work in a FRESH occ process (a new PHP process per docker exec), since
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ ok "as-backup restores into <uid>-bak, byte-identical, original account untouche
|
|||||||
# replace: damage the account, then restore it in place under the same uid.
|
# replace: damage the account, then restore it in place under the same uid.
|
||||||
$X sh -c "rm '$DATA/$U/files/Docs/f1.bin' && echo damaged > '$DATA/$U/files/Docs/f1.bin'"
|
$X sh -c "rm '$DATA/$U/files/Docs/f1.bin' && echo damaged > '$DATA/$U/files/Docs/f1.bin'"
|
||||||
occ files:scan "$U" >/dev/null
|
occ files:scan "$U" >/dev/null
|
||||||
occ backup:user:restore ci "$MANIFEST" --mode=replace >/dev/null || fail "restore replace"
|
if occ backup:user:restore ci "$MANIFEST" --mode=replace --no-interaction >/dev/null 2>&1; then fail "replace of an existing account ran without --force"; fi
|
||||||
|
occ backup:user:restore ci "$MANIFEST" --mode=replace --force >/dev/null || fail "restore replace"
|
||||||
occ files:scan "$U" >/dev/null
|
occ files:scan "$U" >/dev/null
|
||||||
SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
SUM_AFTER=$($X sh -c "cd $DATA/$U/files && find . -type f -print0 | sort -z | xargs -0 md5sum | md5sum")
|
||||||
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore"
|
[ "$SUM_BEFORE" = "$SUM_AFTER" ] || fail "files differ after a replace restore"
|
||||||
|
|||||||
Reference in New Issue
Block a user