From 807353cbe489f1a28d6def62b82fab99862b6d94 Mon Sep 17 00:00:00 2001 From: Dillard Blom Date: Mon, 5 Oct 2026 11:45:37 +0200 Subject: [PATCH] Wire Psalm into CI, fix a real bug it found in user-migration import RepositoryImportSource::copyToFolder() called putContent() on the generic return type of Node::get(), which doesn't declare that method -- only File does. Existing-file overwrite during a per-user restore would have fatally errored at runtime; added an instanceof check and a resource leak fix (finally-close) while there. Also guarded getFileAsStream() against fopen() returning false. Everything else Psalm (strict level) currently flags is mechanical (missing `final` on leaf classes -- applied everywhere safe) or pre-existing array-shape/type gaps, captured in psalm-baseline.xml so CI fails only on new issues from here on, not the whole backlog at once. --- .github/workflows/ci.yml | 12 + lib/AppInfo/Application.php | 2 +- lib/Backend/BackendException.php | 2 +- lib/BackgroundJob/BackupJob.php | 2 +- lib/BackgroundJob/RestoreJob.php | 2 +- lib/Command/Browse.php | 2 +- lib/Command/KeyConfirm.php | 2 +- lib/Command/KeyInit.php | 2 +- lib/Command/KeyKit.php | 2 +- lib/Command/KeySlotAdd.php | 2 +- lib/Command/KeySlotRemove.php | 2 +- lib/Command/KeySlotReplace.php | 2 +- lib/Command/ListSnapshots.php | 2 +- lib/Command/Prune.php | 2 +- lib/Command/Restore.php | 2 +- lib/Command/Retention.php | 2 +- lib/Command/RunBackup.php | 2 +- lib/Command/Status.php | 2 +- lib/Command/TargetAdd.php | 2 +- lib/Command/TargetAppendOnly.php | 2 +- lib/Command/TargetBackends.php | 2 +- lib/Command/TargetLimit.php | 2 +- lib/Command/TargetList.php | 2 +- lib/Command/TargetRemove.php | 2 +- lib/Command/TargetTrustCurrent.php | 2 +- lib/Command/Trash.php | 2 +- lib/Command/TrashRestore.php | 2 +- lib/Controller/AdminApiController.php | 2 +- lib/Crypto/CryptoException.php | 2 +- lib/Db/AppConfigCatalogAnchor.php | 2 +- lib/Db/DbIndexCache.php | 2 +- lib/Db/Run.php | 2 +- lib/Db/RunMapper.php | 2 +- lib/Db/Snapshot.php | 2 +- lib/Db/SnapshotMapper.php | 2 +- lib/Db/Target.php | 2 +- lib/Db/TargetMapper.php | 2 +- .../Version000001Date20261002000000.php | 2 +- .../Version000002Date20261002010000.php | 2 +- .../Version000003Date20261002020000.php | 2 +- .../Version000004Date20261002030000.php | 2 +- .../Version000005Date20261002040000.php | 2 +- lib/Notification/Notifier.php | 2 +- lib/Repository/QuotaExceededException.php | 2 +- lib/Service/AlertService.php | 2 +- lib/Service/BackupService.php | 2 +- lib/Service/KeyService.php | 2 +- lib/Service/KeySlotService.php | 2 +- lib/Service/LeaseService.php | 2 +- lib/Service/PruneService.php | 2 +- lib/Service/RestoreService.php | 2 +- lib/Service/TargetService.php | 2 +- lib/Settings/Admin.php | 2 +- lib/Settings/AdminSection.php | 2 +- lib/SetupCheck/BackupStatusCheck.php | 2 +- lib/UserMigration/RepositoryImportSource.php | 24 +- psalm-baseline.xml | 497 ++++++++++++++++++ psalm.xml | 45 ++ 58 files changed, 626 insertions(+), 60 deletions(-) create mode 100644 psalm-baseline.xml create mode 100644 psalm.xml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1726e07..a8591bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,6 +25,18 @@ jobs: - run: composer install --no-interaction --no-progress - run: vendor/bin/phpunit -c tests/phpunit.xml + psalm: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + extensions: sodium, zip + coverage: none + - run: composer install --no-interaction --no-progress + - run: composer psalm + integration: # Full round trip (key, kit, location, backup, incremental, restore modes, database dump and # restore, retention, trash) on a fresh Nextcloud per version and database. diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 73ba279..facdb09 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -12,7 +12,7 @@ use OCP\AppFramework\Bootstrap\IBootContext; use OCP\AppFramework\Bootstrap\IBootstrap; use OCP\AppFramework\Bootstrap\IRegistrationContext; -class Application extends App implements IBootstrap { +final class Application extends App implements IBootstrap { public const APP_ID = 'ng_backup'; public function __construct() { diff --git a/lib/Backend/BackendException.php b/lib/Backend/BackendException.php index 0255d04..09de989 100644 --- a/lib/Backend/BackendException.php +++ b/lib/Backend/BackendException.php @@ -7,5 +7,5 @@ declare(strict_types=1); namespace OCA\NgBackup\Backend; -class BackendException extends \RuntimeException { +final class BackendException extends \RuntimeException { } diff --git a/lib/BackgroundJob/BackupJob.php b/lib/BackgroundJob/BackupJob.php index 8e33b0f..b2e7e14 100644 --- a/lib/BackgroundJob/BackupJob.php +++ b/lib/BackgroundJob/BackupJob.php @@ -22,7 +22,7 @@ use Psr\Log\LoggerInterface; * With system cron a step may take minutes; with webcron/AJAX cron it is kept short so it fits * in a normal web request (hosted Nextcloud). */ -class BackupJob extends TimedJob { +final class BackupJob extends TimedJob { public function __construct( ITimeFactory $time, private BackupService $backups, diff --git a/lib/BackgroundJob/RestoreJob.php b/lib/BackgroundJob/RestoreJob.php index 37652ff..0df5192 100644 --- a/lib/BackgroundJob/RestoreJob.php +++ b/lib/BackgroundJob/RestoreJob.php @@ -16,7 +16,7 @@ use OCP\BackgroundJob\QueuedJob; use Psr\Log\LoggerInterface; /** Runs a restore requested from the web interface, outside the web request. */ -class RestoreJob extends QueuedJob { +final class RestoreJob extends QueuedJob { public function __construct( ITimeFactory $time, private RunMapper $runs, diff --git a/lib/Command/Browse.php b/lib/Command/Browse.php index 58a7a51..9d2bf37 100644 --- a/lib/Command/Browse.php +++ b/lib/Command/Browse.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class Browse extends Command { +final class Browse extends Command { public function __construct( private TargetService $targets, private RestoreService $restore, diff --git a/lib/Command/KeyConfirm.php b/lib/Command/KeyConfirm.php index 18bf158..3eef094 100644 --- a/lib/Command/KeyConfirm.php +++ b/lib/Command/KeyConfirm.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; -class KeyConfirm extends Command { +final class KeyConfirm extends Command { public const STATEMENT = 'I have downloaded the recovery kit and stored it safely, outside this server. ' . 'I understand that keeping these keys safe is my own responsibility and that backups cannot be restored without them.'; public const CONFIRMATION = 'Yes, I confirm'; diff --git a/lib/Command/KeyInit.php b/lib/Command/KeyInit.php index eb09eea..aad8aac 100644 --- a/lib/Command/KeyInit.php +++ b/lib/Command/KeyInit.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; -class KeyInit extends Command { +final class KeyInit extends Command { public function __construct( private KeyService $keys, ) { diff --git a/lib/Command/KeyKit.php b/lib/Command/KeyKit.php index 86c0e78..7cc12c4 100644 --- a/lib/Command/KeyKit.php +++ b/lib/Command/KeyKit.php @@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class KeyKit extends Command { +final class KeyKit extends Command { public function __construct( private KeyService $keys, private \OCA\NgBackup\Service\AlertService $alerts, diff --git a/lib/Command/KeySlotAdd.php b/lib/Command/KeySlotAdd.php index fd6e7be..6adca4f 100644 --- a/lib/Command/KeySlotAdd.php +++ b/lib/Command/KeySlotAdd.php @@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command; use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeySlotService; -class KeySlotAdd extends KeySlots { +final class KeySlotAdd extends KeySlots { public function __construct(KeyService $keys, KeySlotService $slots) { parent::__construct($keys, $slots, 'add'); } diff --git a/lib/Command/KeySlotRemove.php b/lib/Command/KeySlotRemove.php index b7c90d7..1ca36d3 100644 --- a/lib/Command/KeySlotRemove.php +++ b/lib/Command/KeySlotRemove.php @@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command; use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeySlotService; -class KeySlotRemove extends KeySlots { +final class KeySlotRemove extends KeySlots { public function __construct(KeyService $keys, KeySlotService $slots) { parent::__construct($keys, $slots, 'remove'); } diff --git a/lib/Command/KeySlotReplace.php b/lib/Command/KeySlotReplace.php index ff50d15..0e15a01 100644 --- a/lib/Command/KeySlotReplace.php +++ b/lib/Command/KeySlotReplace.php @@ -10,7 +10,7 @@ namespace OCA\NgBackup\Command; use OCA\NgBackup\Service\KeyService; use OCA\NgBackup\Service\KeySlotService; -class KeySlotReplace extends KeySlots { +final class KeySlotReplace extends KeySlots { public function __construct(KeyService $keys, KeySlotService $slots) { parent::__construct($keys, $slots, 'replace'); } diff --git a/lib/Command/ListSnapshots.php b/lib/Command/ListSnapshots.php index b21993d..ef2ced9 100644 --- a/lib/Command/ListSnapshots.php +++ b/lib/Command/ListSnapshots.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class ListSnapshots extends Command { +final class ListSnapshots extends Command { public function __construct( private TargetService $targets, private SnapshotMapper $snapshots, diff --git a/lib/Command/Prune.php b/lib/Command/Prune.php index 0bbd810..aaafc3e 100644 --- a/lib/Command/Prune.php +++ b/lib/Command/Prune.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class Prune extends Command { +final class Prune extends Command { public function __construct( private TargetService $targets, private PruneService $prune, diff --git a/lib/Command/Restore.php b/lib/Command/Restore.php index 64edec1..6ae877b 100644 --- a/lib/Command/Restore.php +++ b/lib/Command/Restore.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class Restore extends Command { +final class Restore extends Command { public function __construct( private TargetService $targets, private RestoreService $restore, diff --git a/lib/Command/Retention.php b/lib/Command/Retention.php index 425dc6c..9e205e5 100644 --- a/lib/Command/Retention.php +++ b/lib/Command/Retention.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class Retention extends Command { +final class Retention extends Command { public function __construct( private PruneService $prune, ) { diff --git a/lib/Command/RunBackup.php b/lib/Command/RunBackup.php index 570e885..5e5f40e 100644 --- a/lib/Command/RunBackup.php +++ b/lib/Command/RunBackup.php @@ -16,7 +16,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class RunBackup extends Command { +final class RunBackup extends Command { public function __construct( private BackupService $backups, private TargetService $targets, diff --git a/lib/Command/Status.php b/lib/Command/Status.php index b028519..509e03e 100644 --- a/lib/Command/Status.php +++ b/lib/Command/Status.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class Status extends Command { +final class Status extends Command { public function __construct( private KeyService $keys, private TargetService $targets, diff --git a/lib/Command/TargetAdd.php b/lib/Command/TargetAdd.php index 3422628..2dc9337 100644 --- a/lib/Command/TargetAdd.php +++ b/lib/Command/TargetAdd.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class TargetAdd extends Command { +final class TargetAdd extends Command { public function __construct( private TargetService $targets, private KeyService $keys, diff --git a/lib/Command/TargetAppendOnly.php b/lib/Command/TargetAppendOnly.php index e3927bd..7f9a052 100644 --- a/lib/Command/TargetAppendOnly.php +++ b/lib/Command/TargetAppendOnly.php @@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class TargetAppendOnly extends Command { +final class TargetAppendOnly extends Command { public function __construct( private TargetService $targets, private \OCA\NgBackup\Service\AlertService $alerts, diff --git a/lib/Command/TargetBackends.php b/lib/Command/TargetBackends.php index 6fa4741..d83545b 100644 --- a/lib/Command/TargetBackends.php +++ b/lib/Command/TargetBackends.php @@ -12,7 +12,7 @@ use OCA\NgBackup\Service\TargetService; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class TargetBackends extends Base { +final class TargetBackends extends Base { public function __construct( private TargetService $targets, ) { diff --git a/lib/Command/TargetLimit.php b/lib/Command/TargetLimit.php index 6e9a3bd..05be616 100644 --- a/lib/Command/TargetLimit.php +++ b/lib/Command/TargetLimit.php @@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class TargetLimit extends Command { +final class TargetLimit extends Command { public function __construct( private TargetService $targets, ) { diff --git a/lib/Command/TargetList.php b/lib/Command/TargetList.php index e36cdcc..058b10c 100644 --- a/lib/Command/TargetList.php +++ b/lib/Command/TargetList.php @@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -class TargetList extends Base { +final class TargetList extends Base { public function __construct( private TargetService $targets, ) { diff --git a/lib/Command/TargetRemove.php b/lib/Command/TargetRemove.php index 58de093..2d8473b 100644 --- a/lib/Command/TargetRemove.php +++ b/lib/Command/TargetRemove.php @@ -13,7 +13,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class TargetRemove extends Command { +final class TargetRemove extends Command { public function __construct( private TargetService $targets, private \OCA\NgBackup\Service\AlertService $alerts, diff --git a/lib/Command/TargetTrustCurrent.php b/lib/Command/TargetTrustCurrent.php index 3618d6c..caa5acc 100644 --- a/lib/Command/TargetTrustCurrent.php +++ b/lib/Command/TargetTrustCurrent.php @@ -22,7 +22,7 @@ use Symfony\Component\Console\Question\Question; * legitimate cases (e.g. the location was restored from the provider's own snapshot), only from * occ, audited and announced to all administrators. */ -class TargetTrustCurrent extends Command { +final class TargetTrustCurrent extends Command { public function __construct( private TargetService $targets, private IAppConfig $appConfig, diff --git a/lib/Command/Trash.php b/lib/Command/Trash.php index 3ca0e84..d63a01d 100644 --- a/lib/Command/Trash.php +++ b/lib/Command/Trash.php @@ -15,7 +15,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class Trash extends Command { +final class Trash extends Command { public function __construct( private TargetService $targets, private KeyService $keys, diff --git a/lib/Command/TrashRestore.php b/lib/Command/TrashRestore.php index f05d866..72338cc 100644 --- a/lib/Command/TrashRestore.php +++ b/lib/Command/TrashRestore.php @@ -14,7 +14,7 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -class TrashRestore extends Command { +final class TrashRestore extends Command { public function __construct( private TargetService $targets, private PruneService $prune, diff --git a/lib/Controller/AdminApiController.php b/lib/Controller/AdminApiController.php index 1f06d53..3c42182 100644 --- a/lib/Controller/AdminApiController.php +++ b/lib/Controller/AdminApiController.php @@ -32,7 +32,7 @@ use OCP\IUserSession; * JSON API for the admin settings page. Every method requires an admin (the AppFramework * default for controllers without NoAdminRequired) and a valid CSRF token. */ -class AdminApiController extends Controller { +final class AdminApiController extends Controller { public function __construct( IRequest $request, private KeyService $keys, diff --git a/lib/Crypto/CryptoException.php b/lib/Crypto/CryptoException.php index ad12319..1913de3 100644 --- a/lib/Crypto/CryptoException.php +++ b/lib/Crypto/CryptoException.php @@ -7,5 +7,5 @@ declare(strict_types=1); namespace OCA\NgBackup\Crypto; -class CryptoException extends \RuntimeException { +final class CryptoException extends \RuntimeException { } diff --git a/lib/Db/AppConfigCatalogAnchor.php b/lib/Db/AppConfigCatalogAnchor.php index a7ad178..5acf7d4 100644 --- a/lib/Db/AppConfigCatalogAnchor.php +++ b/lib/Db/AppConfigCatalogAnchor.php @@ -12,7 +12,7 @@ use OCA\NgBackup\Repository\ICatalogAnchor; use OCP\IAppConfig; /** Keeps the newest catalog generation seen per repository in the app config (outside the location). */ -class AppConfigCatalogAnchor implements ICatalogAnchor { +final class AppConfigCatalogAnchor implements ICatalogAnchor { public function __construct( private IAppConfig $appConfig, ) { diff --git a/lib/Db/DbIndexCache.php b/lib/Db/DbIndexCache.php index bb61dab..4aace2f 100644 --- a/lib/Db/DbIndexCache.php +++ b/lib/Db/DbIndexCache.php @@ -11,7 +11,7 @@ use OCA\NgBackup\Repository\IIndexCache; use OCP\DB\Exception; use OCP\IDBConnection; -class DbIndexCache implements IIndexCache { +final class DbIndexCache implements IIndexCache { public function __construct( private IDBConnection $db, ) { diff --git a/lib/Db/Run.php b/lib/Db/Run.php index b881d53..fc2ad79 100644 --- a/lib/Db/Run.php +++ b/lib/Db/Run.php @@ -33,7 +33,7 @@ use OCP\AppFramework\Db\Entity; * @method int|null getFinishedAt() * @method void setFinishedAt(?int $v) */ -class Run extends Entity { +final class Run extends Entity { public const RUNNING = 'running'; public const DONE = 'done'; public const FAILED = 'failed'; diff --git a/lib/Db/RunMapper.php b/lib/Db/RunMapper.php index cd8e47f..e700370 100644 --- a/lib/Db/RunMapper.php +++ b/lib/Db/RunMapper.php @@ -12,7 +12,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @template-extends QBMapper */ -class RunMapper extends QBMapper { +final class RunMapper extends QBMapper { public function __construct(IDBConnection $db) { parent::__construct($db, 'ngb_runs', Run::class); } diff --git a/lib/Db/Snapshot.php b/lib/Db/Snapshot.php index 131f582..55eb769 100644 --- a/lib/Db/Snapshot.php +++ b/lib/Db/Snapshot.php @@ -25,7 +25,7 @@ use OCP\AppFramework\Db\Entity; * @method int|null getBytes() * @method void setBytes(?int $v) */ -class Snapshot extends Entity { +final class Snapshot extends Entity { protected int $targetId = 0; protected string $snapshotId = ''; protected string $kind = ''; diff --git a/lib/Db/SnapshotMapper.php b/lib/Db/SnapshotMapper.php index 17847f9..58731de 100644 --- a/lib/Db/SnapshotMapper.php +++ b/lib/Db/SnapshotMapper.php @@ -12,7 +12,7 @@ use OCP\DB\QueryBuilder\IQueryBuilder; use OCP\IDBConnection; /** @template-extends QBMapper */ -class SnapshotMapper extends QBMapper { +final class SnapshotMapper extends QBMapper { public function __construct(IDBConnection $db) { parent::__construct($db, 'ngb_snapshots', Snapshot::class); } diff --git a/lib/Db/Target.php b/lib/Db/Target.php index f3a316c..0930a62 100644 --- a/lib/Db/Target.php +++ b/lib/Db/Target.php @@ -29,7 +29,7 @@ use OCP\AppFramework\Db\Entity; * @method int getCreatedAt() * @method void setCreatedAt(int $v) */ -class Target extends Entity { +final class Target extends Entity { protected string $name = ''; protected string $backend = ''; protected string $auth = ''; diff --git a/lib/Db/TargetMapper.php b/lib/Db/TargetMapper.php index d815c57..614c83e 100644 --- a/lib/Db/TargetMapper.php +++ b/lib/Db/TargetMapper.php @@ -11,7 +11,7 @@ use OCP\AppFramework\Db\QBMapper; use OCP\IDBConnection; /** @template-extends QBMapper */ -class TargetMapper extends QBMapper { +final class TargetMapper extends QBMapper { public function __construct(IDBConnection $db) { parent::__construct($db, 'ngb_targets', Target::class); } diff --git a/lib/Migration/Version000001Date20261002000000.php b/lib/Migration/Version000001Date20261002000000.php index a88ec99..bc78847 100644 --- a/lib/Migration/Version000001Date20261002000000.php +++ b/lib/Migration/Version000001Date20261002000000.php @@ -13,7 +13,7 @@ use OCP\DB\Types; use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; -class Version000001Date20261002000000 extends SimpleMigrationStep { +final class Version000001Date20261002000000 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/lib/Migration/Version000002Date20261002010000.php b/lib/Migration/Version000002Date20261002010000.php index 2a6cbab..8a8c682 100644 --- a/lib/Migration/Version000002Date20261002010000.php +++ b/lib/Migration/Version000002Date20261002010000.php @@ -14,7 +14,7 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** Local cache of the per-pack index files, so opening a repository does not download them all. */ -class Version000002Date20261002010000 extends SimpleMigrationStep { +final class Version000002Date20261002010000 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/lib/Migration/Version000003Date20261002020000.php b/lib/Migration/Version000003Date20261002020000.php index f9f359c..b058b45 100644 --- a/lib/Migration/Version000003Date20261002020000.php +++ b/lib/Migration/Version000003Date20261002020000.php @@ -14,7 +14,7 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** Append-only locations: NG Backup never deletes anything there (cleanup by the location's own lifecycle rules). */ -class Version000003Date20261002020000 extends SimpleMigrationStep { +final class Version000003Date20261002020000 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/lib/Migration/Version000004Date20261002030000.php b/lib/Migration/Version000004Date20261002030000.php index a2e52a6..b9a2840 100644 --- a/lib/Migration/Version000004Date20261002030000.php +++ b/lib/Migration/Version000004Date20261002030000.php @@ -14,7 +14,7 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** Optional size limit per location (cost control; S3 has no bucket quota). */ -class Version000004Date20261002030000 extends SimpleMigrationStep { +final class Version000004Date20261002030000 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/lib/Migration/Version000005Date20261002040000.php b/lib/Migration/Version000005Date20261002040000.php index f7a2dbe..a6aa0a5 100644 --- a/lib/Migration/Version000005Date20261002040000.php +++ b/lib/Migration/Version000005Date20261002040000.php @@ -14,7 +14,7 @@ use OCP\Migration\IOutput; use OCP\Migration\SimpleMigrationStep; /** Leases with an expiry: a killed process cannot block a location for longer than its lease. */ -class Version000005Date20261002040000 extends SimpleMigrationStep { +final class Version000005Date20261002040000 extends SimpleMigrationStep { public function changeSchema(IOutput $output, Closure $schemaClosure, array $options): ?ISchemaWrapper { /** @var ISchemaWrapper $schema */ $schema = $schemaClosure(); diff --git a/lib/Notification/Notifier.php b/lib/Notification/Notifier.php index ff34354..e5cf2e8 100644 --- a/lib/Notification/Notifier.php +++ b/lib/Notification/Notifier.php @@ -14,7 +14,7 @@ use OCP\Notification\INotification; use OCP\Notification\INotifier; use OCP\Notification\UnknownNotificationException; -class Notifier implements INotifier { +final class Notifier implements INotifier { public function __construct( private IFactory $l10n, private IURLGenerator $url, diff --git a/lib/Repository/QuotaExceededException.php b/lib/Repository/QuotaExceededException.php index 2e970af..e013c55 100644 --- a/lib/Repository/QuotaExceededException.php +++ b/lib/Repository/QuotaExceededException.php @@ -7,5 +7,5 @@ declare(strict_types=1); namespace OCA\NgBackup\Repository; -class QuotaExceededException extends RepositoryException { +final class QuotaExceededException extends RepositoryException { } diff --git a/lib/Service/AlertService.php b/lib/Service/AlertService.php index 2c6a3fd..f5051ae 100644 --- a/lib/Service/AlertService.php +++ b/lib/Service/AlertService.php @@ -15,7 +15,7 @@ use OCP\IGroupManager; use OCP\Notification\IManager; /** Notifies administrators about failed runs and locations without a recent successful backup. */ -class AlertService { +final class AlertService { public const STALE_AFTER = 2 * 86400; public function __construct( diff --git a/lib/Service/BackupService.php b/lib/Service/BackupService.php index d6507d7..3d2ae34 100644 --- a/lib/Service/BackupService.php +++ b/lib/Service/BackupService.php @@ -28,7 +28,7 @@ use Psr\Log\LoggerInterface; * Excluded by default: previews, log files, the updater's backups and each user's cache and * temporary uploads (all regenerable or temporary). */ -class BackupService { +final class BackupService { public const KIND_FULL = 'full'; public function __construct( diff --git a/lib/Service/KeyService.php b/lib/Service/KeyService.php index 67c4faf..06ac581 100644 --- a/lib/Service/KeyService.php +++ b/lib/Service/KeyService.php @@ -22,7 +22,7 @@ use OCP\Security\ICrypto; * into the recovery kit, so backups can be restored on a fresh server with the passphrase. * - The admin must confirm having stored the recovery kit before the first backup runs. */ -class KeyService { +final class KeyService { private const KEY_MASTER = 'master_key'; private const KEY_WRAPPED = 'master_key_wrapped'; private const KEY_CONFIRMED = 'recovery_kit_confirmed'; diff --git a/lib/Service/KeySlotService.php b/lib/Service/KeySlotService.php index fd07026..cf6ed86 100644 --- a/lib/Service/KeySlotService.php +++ b/lib/Service/KeySlotService.php @@ -13,7 +13,7 @@ namespace OCA\NgBackup\Service; * is written to all locations and announced to all administrators; a new recovery kit must be * downloaded and confirmed afterwards (backups keep running meanwhile). */ -class KeySlotService { +final class KeySlotService { public function __construct( private KeyService $keys, private TargetService $targets, diff --git a/lib/Service/LeaseService.php b/lib/Service/LeaseService.php index 3609365..874dbcb 100644 --- a/lib/Service/LeaseService.php +++ b/lib/Service/LeaseService.php @@ -19,7 +19,7 @@ use OCP\Lock\LockedException; * holder keeps refreshing it. Nextcloud's lock is only held for the few milliseconds in which a * lease is checked and written. */ -class LeaseService { +final class LeaseService { public const SHARED = 's'; public const EXCLUSIVE = 'x'; diff --git a/lib/Service/PruneService.php b/lib/Service/PruneService.php index 478591c..97b2d6e 100644 --- a/lib/Service/PruneService.php +++ b/lib/Service/PruneService.php @@ -20,7 +20,7 @@ use OCP\Lock\LockedException; * prunes data no remaining snapshot uses. Never runs on append-only locations, never while a * backup to the same location is running (the location lock is shared with BackupService). */ -class PruneService { +final class PruneService { public function __construct( private TargetService $targets, private RunMapper $runs, diff --git a/lib/Service/RestoreService.php b/lib/Service/RestoreService.php index 95a1d95..c68c677 100644 --- a/lib/Service/RestoreService.php +++ b/lib/Service/RestoreService.php @@ -28,7 +28,7 @@ use OCP\Files\NotFoundException; * - merge: into the original place, existing files are overwritten (old content kept as a version) * - replace: like merge, and files that are not in the snapshot are moved to the trash */ -class RestoreService { +final class RestoreService { public const MODE_NEW_FOLDER = 'new-folder'; public const MODE_MERGE = 'merge'; public const MODE_REPLACE = 'replace'; diff --git a/lib/Service/TargetService.php b/lib/Service/TargetService.php index e9e9d25..67a81a9 100644 --- a/lib/Service/TargetService.php +++ b/lib/Service/TargetService.php @@ -22,7 +22,7 @@ use OCP\Server; * object is built only while it is used; nothing is mounted. files_external is loaded for its * classes but its enabled/disabled state is left as the admin set it. */ -class TargetService { +final class TargetService { public function __construct( private TargetMapper $mapper, private KeyService $keys, diff --git a/lib/Settings/Admin.php b/lib/Settings/Admin.php index aebbb3a..76e0fac 100644 --- a/lib/Settings/Admin.php +++ b/lib/Settings/Admin.php @@ -11,7 +11,7 @@ use OCP\AppFramework\Http\TemplateResponse; use OCP\Settings\ISettings; use OCP\Util; -class Admin implements ISettings { +final class Admin implements ISettings { public function getForm(): TemplateResponse { Util::addScript('ng_backup', 'ng_backup-admin'); Util::addStyle('ng_backup', 'ng_backup-admin'); diff --git a/lib/Settings/AdminSection.php b/lib/Settings/AdminSection.php index 3fcf1d4..5a7d98f 100644 --- a/lib/Settings/AdminSection.php +++ b/lib/Settings/AdminSection.php @@ -11,7 +11,7 @@ use OCP\IL10N; use OCP\IURLGenerator; use OCP\Settings\IIconSection; -class AdminSection implements IIconSection { +final class AdminSection implements IIconSection { public function __construct( private IL10N $l, private IURLGenerator $url, diff --git a/lib/SetupCheck/BackupStatusCheck.php b/lib/SetupCheck/BackupStatusCheck.php index 6788805..10ab252 100644 --- a/lib/SetupCheck/BackupStatusCheck.php +++ b/lib/SetupCheck/BackupStatusCheck.php @@ -19,7 +19,7 @@ use OCP\SetupCheck\ISetupCheck; use OCP\SetupCheck\SetupResult; /** Shows the backup state in the admin overview (Security & setup warnings). */ -class BackupStatusCheck implements ISetupCheck { +final class BackupStatusCheck implements ISetupCheck { public function __construct( private IL10N $l, private IURLGenerator $url, diff --git a/lib/UserMigration/RepositoryImportSource.php b/lib/UserMigration/RepositoryImportSource.php index 94f4460..a8b4898 100644 --- a/lib/UserMigration/RepositoryImportSource.php +++ b/lib/UserMigration/RepositoryImportSource.php @@ -8,6 +8,7 @@ declare(strict_types=1); namespace OCA\NgBackup\UserMigration; use OCA\NgBackup\Repository\Repository; +use OCP\Files\File; use OCP\Files\Folder; use OCP\UserMigration\IImportSource; use OCP\UserMigration\UserMigrationException; @@ -30,10 +31,14 @@ final class RepositoryImportSource implements IImportSource { return $data; } + /** @return resource */ public function getFileAsStream(string $path) { $entry = $this->entry($path, 'f'); // Phase 0: buffer in php://temp (memory up to 8 MiB, then a temp file for this one file). $stream = fopen('php://temp/maxmemory:' . (8 * 1048576), 'w+b'); + if ($stream === false) { + throw new UserMigrationException('Cannot buffer ' . $path); + } foreach ($this->repo->readBlobs($entry['b']) as $data) { for ($done = 0, $len = strlen($data); $done < $len; $done += $n) { $n = fwrite($stream, $done === 0 ? $data : substr($data, $done)); @@ -51,6 +56,7 @@ final class RepositoryImportSource implements IImportSource { $prefix = trim($path, '/'); $prefix = $prefix === '' ? '' : $prefix . '/'; $names = []; + /** @var string $p */ foreach (array_keys($this->manifest['entries']) as $p) { if ($prefix === '' || str_starts_with($p, $prefix)) { $rest = substr($p, strlen($prefix)); @@ -70,6 +76,7 @@ final class RepositoryImportSource implements IImportSource { if (isset($this->manifest['entries'][$p])) { return true; } + /** @var string $k */ foreach (array_keys($this->manifest['entries']) as $k) { if (str_starts_with($k, $p . '/')) { return true; @@ -91,12 +98,17 @@ final class RepositoryImportSource implements IImportSource { $this->copyToFolder($sub, $path); } else { $stream = $this->getFileAsStream($path); - if ($destination->nodeExists($name)) { - $destination->get($name)->putContent($stream); - } else { - $destination->newFile($name, $stream); - } - if (is_resource($stream)) { + try { + if ($destination->nodeExists($name)) { + $existing = $destination->get($name); + if (!$existing instanceof File) { + throw new UserMigrationException("$path exists and is not a file"); + } + $existing->putContent($stream); + } else { + $destination->newFile($name, $stream); + } + } finally { fclose($stream); } } diff --git a/psalm-baseline.xml b/psalm-baseline.xml new file mode 100644 index 0000000..98da393 --- /dev/null +++ b/psalm-baseline.xml @@ -0,0 +1,497 @@ + + + + + + + + + $p->jsonSerialize()]]> + $p->jsonSerialize()]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + prune->policy()->toArray())]]> + + + + + getSnapshotId()]]> + + + + + + getFinishedAt()]]> + + + + + getRepositoryId()]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getSnapshotId()]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + $gen, 'hash' => $hash])]]> + + + + + + + + + $end[array_search($c, $columns, true)]]]> + $q($c)]]> + $this->db->quoteIdentifier($c)]]> + $this->db->quoteIdentifier($c)]]> + + + + + + + + + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + db]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + createTable('ngb_targets')]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + getTable('ngb_targets')]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + buffer]]> + + + targetSize + 8 * 1048576), 'w+b')]]> + + + buffer)]]> + + + + + + + + $snapshotId] + $stats]]> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1]), JSON_UNESCAPED_SLASHES)]]> + + + + + + + + + + + + + + + + + + + + 1, + 'app' => 'NG Backup', + 'fingerprint' => $this->fingerprint(), + 'kit_version' => $this->kitVersion(), + 'confirmation_code' => $this->kitCode(), + 'created' => gmdate('c'), + 'wrapped_key' => $this->wrappedKey(), + 'slots' => $this->slots(), + 'instructions' => 'Keep this file safe, outside this server. Any one of the passphrases of the slots listed here opens it. ' + . 'To restore on a new server: install NG Backup, add the backup location and enter this kit with one passphrase. ' + . 'Without the kit (or the location) and a passphrase, the backups cannot be decrypted.', + ]]]> + + + + + + ['slot' => $s['slot'], 'label' => $s['label'], 'created' => $s['created']], $this->wrappedKey()['slots'])]]> + + + + + + + strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $c) ?? '')]]> + + + ]]> + + + crypto->decrypt($enc), true)]]> + + + + + $keep, 'forgotten' => $forget, 'purged' => $purged, 'prune' => $prune]]]> + + + $fn()]]> + + + >, forgotten: list, purged: list, prune: array}]]> + + + + + + + + root]]> + + + + ]]> + + + + + + + + + + + + + + + + + + factory()->describeBackends()]]> + + + , parameters:array}>]]> + + + + + + + + + diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..d694c57 --- /dev/null +++ b/psalm.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +