diff --git a/.github/workflows/psalm.yml b/.github/workflows/psalm.yml index 7be48f26..3d29b70e 100644 --- a/.github/workflows/psalm.yml +++ b/.github/workflows/psalm.yml @@ -2,6 +2,9 @@ # # https://github.com/nextcloud/.github # https://docs.github.com/en/actions/learn-github-actions/sharing-workflows-with-your-organization +# +# SPDX-FileCopyrightText: 2022-2024 Nextcloud GmbH and Nextcloud contributors +# SPDX-License-Identifier: MIT name: Static analysis @@ -17,6 +20,9 @@ concurrency: group: psalm-${{ github.head_ref || github.run_id }} cancel-in-progress: true +permissions: + contents: read + jobs: static-analysis: runs-on: ubuntu-latest @@ -32,6 +38,8 @@ jobs: php-version: 8.1 coverage: none ini-file: development + # Temporary workaround for missing pcntl_* in PHP 8.3 + ini-values: disable_functions= env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -39,4 +47,4 @@ jobs: run: composer i - name: Run coding standards check - run: composer run psalm + run: composer run psalm -- --threads=1 --monochrome --no-progress --output-format=github diff --git a/index.php b/index.php index 24aad88c..4a16553e 100644 --- a/index.php +++ b/index.php @@ -553,11 +553,9 @@ private function getUpdateServerResponse() { * * @throws \Exception */ - public function downloadUpdate() { + public function downloadUpdate(?string $url = null) { $this->silentLog('[info] downloadUpdate()'); - $response = $this->getUpdateServerResponse(); - $storageLocation = $this->getUpdateDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/downloads/'; if (file_exists($storageLocation)) { $this->silentLog('[info] storage location exists'); @@ -568,8 +566,26 @@ public function downloadUpdate() { throw new \Exception('Could not mkdir storage location'); } - $fp = fopen($storageLocation . basename($response['url']), 'w+'); - $ch = curl_init($response['url']); + $downloadURL = ''; + if ($url) { + // If a URL is provided, use it directly + $downloadURL = $url; + } else { + // Otherwise, get the download URLs from the update server + $response = $this->getUpdateServerResponse(); + + if (!isset($response['url']) || !is_string($response['url'])) { + throw new \Exception('Response from update server is missing url'); + } + $downloadURL = $response['url']; + } + + if (!$downloadURL) { + throw new \Exception('No download URL provided or available from update server'); + } + + $fp = fopen($storageLocation . basename($downloadURL), 'w+'); + $ch = curl_init($downloadURL); curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_USERAGENT => 'Nextcloud Updater', @@ -611,7 +627,7 @@ public function downloadUpdate() { $message .= ' - curl error message: ' . $curlErrorMessage; } - $message .= ' - URL: ' . htmlentities($response['url']); + $message .= ' - URL: ' . htmlentities($downloadURL); throw new \Exception($message); } @@ -645,7 +661,7 @@ private function getDownloadedFilePath() { * * @throws \Exception */ - public function verifyIntegrity() { + public function verifyIntegrity(?string $urlOverride = null): void { $this->silentLog('[info] verifyIntegrity()'); if ($this->getCurrentReleaseChannel() === 'daily') { @@ -653,6 +669,11 @@ public function verifyIntegrity() { return; } + if ($urlOverride) { + $this->silentLog('[info] custom download url provided, cannot verify signature'); + return; + } + $response = $this->getUpdateServerResponse(); if (empty($response['signature'])) { throw new \Exception('No signature specified for defined update'); diff --git a/lib/UpdateCommand.php b/lib/UpdateCommand.php index 9a438cd9..65aedc73 100644 --- a/lib/UpdateCommand.php +++ b/lib/UpdateCommand.php @@ -41,6 +41,7 @@ class UpdateCommand extends Command { protected $skipBackup = false; protected bool $skipUpgrade = false; + protected string $urlOverride = ''; /** @var array strings of text for stages of updater */ protected $checkTexts = [ @@ -65,7 +66,8 @@ protected function configure() { ->setDescription('Updates the code of an Nextcloud instance') ->setHelp("This command fetches the latest code that is announced via the updater server and safely replaces the existing code with the new one.") ->addOption('no-backup', null, InputOption::VALUE_NONE, 'Skip backup of current Nextcloud version') - ->addOption('no-upgrade', null, InputOption::VALUE_NONE, "Don't automatically run occ upgrade"); + ->addOption('no-upgrade', null, InputOption::VALUE_NONE, "Don't automatically run occ upgrade") + ->addOption('url', null, InputOption::VALUE_OPTIONAL, 'The URL of the Nextcloud release to download'); } public static function getUpdaterVersion(): string { @@ -78,8 +80,9 @@ public static function getUpdaterVersion(): string { } protected function execute(InputInterface $input, OutputInterface $output) { - $this->skipBackup = $input->getOption('no-backup'); - $this->skipUpgrade = $input->getOption('no-upgrade'); + $this->skipBackup = (bool)$input->getOption('no-backup'); + $this->skipUpgrade = (bool)$input->getOption('no-upgrade'); + $this->urlOverride = (string)$input->getOption('url'); $version = static::getUpdaterVersion(); $output->writeln('Nextcloud Updater - version: ' . $version); @@ -152,7 +155,12 @@ protected function execute(InputInterface $input, OutputInterface $output) { $output->writeln('Current version is ' . $this->updater->getCurrentVersion() . '.'); // needs to be called that early because otherwise updateAvailable() returns false - $updateString = $this->updater->checkForUpdate(); + if ($this->urlOverride) { + $this->updater->log('[info] Using URL override: ' . $this->urlOverride); + $updateString = 'Update check forced with URL override: ' . $this->urlOverride; + } else { + $updateString = $this->updater->checkForUpdate(); + } $output->writeln(''); @@ -165,9 +173,11 @@ protected function execute(InputInterface $input, OutputInterface $output) { $output->writeln(''); - if (!$this->updater->updateAvailable() && $stepNumber === 0) { - $output->writeln('Nothing to do.'); - return 0; + if (!$this->urlOverride) { + if (!$this->updater->updateAvailable() && $stepNumber === 0) { + $output->writeln('Nothing to do.'); + return 0; + } } $questionText = 'Start update'; @@ -374,10 +384,10 @@ protected function executeStep($step) { } break; case 4: - $this->updater->downloadUpdate(); + $this->updater->downloadUpdate($this->urlOverride); break; case 5: - $this->updater->verifyIntegrity(); + $this->updater->verifyIntegrity($this->urlOverride); break; case 6: $this->updater->extractDownload(); diff --git a/lib/Updater.php b/lib/Updater.php index 06f65e8b..16e3a721 100644 --- a/lib/Updater.php +++ b/lib/Updater.php @@ -517,11 +517,9 @@ private function getUpdateServerResponse() { * * @throws \Exception */ - public function downloadUpdate() { + public function downloadUpdate(?string $url = null) { $this->silentLog('[info] downloadUpdate()'); - $response = $this->getUpdateServerResponse(); - $storageLocation = $this->getUpdateDirectoryLocation() . '/updater-'.$this->getConfigOption('instanceid') . '/downloads/'; if (file_exists($storageLocation)) { $this->silentLog('[info] storage location exists'); @@ -532,8 +530,26 @@ public function downloadUpdate() { throw new \Exception('Could not mkdir storage location'); } - $fp = fopen($storageLocation . basename($response['url']), 'w+'); - $ch = curl_init($response['url']); + $downloadURL = ''; + if ($url) { + // If a URL is provided, use it directly + $downloadURL = $url; + } else { + // Otherwise, get the download URLs from the update server + $response = $this->getUpdateServerResponse(); + + if (!isset($response['url']) || !is_string($response['url'])) { + throw new \Exception('Response from update server is missing url'); + } + $downloadURL = $response['url']; + } + + if (!$downloadURL) { + throw new \Exception('No download URL provided or available from update server'); + } + + $fp = fopen($storageLocation . basename($downloadURL), 'w+'); + $ch = curl_init($downloadURL); curl_setopt_array($ch, [ CURLOPT_FILE => $fp, CURLOPT_USERAGENT => 'Nextcloud Updater', @@ -575,7 +591,7 @@ public function downloadUpdate() { $message .= ' - curl error message: ' . $curlErrorMessage; } - $message .= ' - URL: ' . htmlentities($response['url']); + $message .= ' - URL: ' . htmlentities($downloadURL); throw new \Exception($message); } @@ -609,7 +625,7 @@ private function getDownloadedFilePath() { * * @throws \Exception */ - public function verifyIntegrity() { + public function verifyIntegrity(?string $urlOverride = null): void { $this->silentLog('[info] verifyIntegrity()'); if ($this->getCurrentReleaseChannel() === 'daily') { @@ -617,6 +633,11 @@ public function verifyIntegrity() { return; } + if ($urlOverride) { + $this->silentLog('[info] custom download url provided, cannot verify signature'); + return; + } + $response = $this->getUpdateServerResponse(); if (empty($response['signature'])) { throw new \Exception('No signature specified for defined update'); diff --git a/updater.phar b/updater.phar index e406bfe0..7f43af10 100755 Binary files a/updater.phar and b/updater.phar differ diff --git a/vendor/autoload.php b/vendor/autoload.php index e97f2aed..f52071e9 100644 --- a/vendor/autoload.php +++ b/vendor/autoload.php @@ -14,10 +14,7 @@ echo $err; } } - trigger_error( - $err, - E_USER_ERROR - ); + throw new RuntimeException($err); } require_once __DIR__ . '/composer/autoload_real.php'; diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php index 51e734a7..2052022f 100644 --- a/vendor/composer/InstalledVersions.php +++ b/vendor/composer/InstalledVersions.php @@ -26,12 +26,23 @@ */ class InstalledVersions { + /** + * @var string|null if set (by reflection by Composer), this should be set to the path where this class is being copied to + * @internal + */ + private static $selfDir = null; + /** * @var mixed[]|null * @psalm-var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array}|array{}|null */ private static $installed; + /** + * @var bool + */ + private static $installedIsLocalDir; + /** * @var bool|null */ @@ -309,6 +320,24 @@ public static function reload($data) { self::$installed = $data; self::$installedByVendor = array(); + + // when using reload, we disable the duplicate protection to ensure that self::$installed data is + // always returned, but we cannot know whether it comes from the installed.php in __DIR__ or not, + // so we have to assume it does not, and that may result in duplicate data being returned when listing + // all installed packages for example + self::$installedIsLocalDir = false; + } + + /** + * @return string + */ + private static function getSelfDir() + { + if (self::$selfDir === null) { + self::$selfDir = strtr(__DIR__, '\\', '/'); + } + + return self::$selfDir; } /** @@ -322,19 +351,27 @@ private static function getInstalled() } $installed = array(); + $copiedLocalDir = false; if (self::$canGetVendors) { + $selfDir = self::getSelfDir(); foreach (ClassLoader::getRegisteredLoaders() as $vendorDir => $loader) { + $vendorDir = strtr($vendorDir, '\\', '/'); if (isset(self::$installedByVendor[$vendorDir])) { $installed[] = self::$installedByVendor[$vendorDir]; } elseif (is_file($vendorDir.'/composer/installed.php')) { /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */ $required = require $vendorDir.'/composer/installed.php'; - $installed[] = self::$installedByVendor[$vendorDir] = $required; - if (null === self::$installed && strtr($vendorDir.'/composer', '\\', '/') === strtr(__DIR__, '\\', '/')) { - self::$installed = $installed[count($installed) - 1]; + self::$installedByVendor[$vendorDir] = $required; + $installed[] = $required; + if (self::$installed === null && $vendorDir.'/composer' === $selfDir) { + self::$installed = $required; + self::$installedIsLocalDir = true; } } + if (self::$installedIsLocalDir && $vendorDir.'/composer' === $selfDir) { + $copiedLocalDir = true; + } } } @@ -350,7 +387,7 @@ private static function getInstalled() } } - if (self::$installed !== array()) { + if (self::$installed !== array() && !$copiedLocalDir) { $installed[] = self::$installed; } diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php index de5be70d..d0a887f5 100644 --- a/vendor/composer/installed.php +++ b/vendor/composer/installed.php @@ -3,7 +3,7 @@ 'name' => '__root__', 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => 'b3b17cf837de98718ef2bc2277fbc1db24f4288f', + 'reference' => '1ed21d3dfe750ed9fc1ebe42927b1a0955a42c27', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), @@ -13,7 +13,7 @@ '__root__' => array( 'pretty_version' => 'dev-master', 'version' => 'dev-master', - 'reference' => 'b3b17cf837de98718ef2bc2277fbc1db24f4288f', + 'reference' => '1ed21d3dfe750ed9fc1ebe42927b1a0955a42c27', 'type' => 'library', 'install_path' => __DIR__ . '/../../', 'aliases' => array(), diff --git a/vendor/composer/platform_check.php b/vendor/composer/platform_check.php index 580fa960..d2225c7d 100644 --- a/vendor/composer/platform_check.php +++ b/vendor/composer/platform_check.php @@ -19,8 +19,7 @@ echo 'Composer detected issues in your platform:' . PHP_EOL.PHP_EOL . str_replace('You are running '.PHP_VERSION.'.', '', implode(PHP_EOL, $issues)) . PHP_EOL.PHP_EOL; } } - trigger_error( - 'Composer detected issues in your platform: ' . implode(' ', $issues), - E_USER_ERROR + throw new \RuntimeException( + 'Composer detected issues in your platform: ' . implode(' ', $issues) ); }