diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml
new file mode 100644
index 00000000..db50b9b8
--- /dev/null
+++ b/.github/workflows/phpunit.yml
@@ -0,0 +1,66 @@
+# SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
+# SPDX-License-Identifier: MIT
+
+name: PHPUnit
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - master
+ - stable*
+
+permissions:
+ contents: read
+
+concurrency:
+ group: phpunit-${{ github.head_ref || github.run_id }}
+ cancel-in-progress: true
+
+jobs:
+ phpunit:
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-versions: ["8.2", "8.3", "8.4"]
+
+ name: PHPUnit PHP ${{ matrix.php-versions }}
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
+ with:
+ persist-credentials: false
+
+ - name: Set up php ${{ matrix.php-versions }}
+ uses: shivammathur/setup-php@ec406be512d7077f68eed36e63f4d91bc006edc4 # v2.35.4
+ with:
+ php-version: ${{ matrix.php-versions }}
+ extensions: ctype, curl, dom, fileinfo, gd, iconv, intl, json, libxml, mbstring, openssl, posix, session, simplexml, xmlreader, xmlwriter, zip, zlib
+ coverage: none
+ ini-file: development
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Install dependencies
+ run: |
+ composer i
+ composer bin phpunit install
+
+ - name: PHPUnit
+ run: composer run test:unit
+
+ summary:
+ permissions:
+ contents: none
+ runs-on: ubuntu-latest-low
+ needs: phpunit
+
+ if: always()
+
+ name: phpunit-summary
+
+ steps:
+ - name: Summary status
+ run: if ${{ needs.phpunit.result != 'success' && needs.phpunit.result != 'skipped' }}; then exit 1; fi
diff --git a/.gitignore b/.gitignore
index 75a13176..4eacecc9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,3 +12,4 @@
/vendor/symfony/service-contracts/Test
/vendor-bin/**/vendor
.php-cs-fixer.cache
+.phpunit.result.cache
diff --git a/Makefile b/Makefile
index 273f7e7c..2a6503c7 100644
--- a/Makefile
+++ b/Makefile
@@ -21,9 +21,15 @@ index.php: lib/UpdateException.php lib/LogException.php lib/Updater.php index.we
test/vendor:
composer bin tests install
+vendor/bin/phpunit:
+ composer bin phpunit install
+
test: updater.phar test/vendor
cd tests && ../vendor/bin/behat
+test-unit: vendor/bin/phpunit
+ composer run test:unit
+
test-cli: updater.phar test/vendor
cd tests && ../vendor/bin/behat features/cli.feature
diff --git a/README.md b/README.md
index c518e93c..4c23de2e 100644
--- a/README.md
+++ b/README.md
@@ -192,29 +192,47 @@ Described here are both relevant aspects of the Updater itself as well as surrou
### Updater components
-For each mode the Updater supports - Web and command line - a dedicated artifact is generated. However, all common operations are located in shared code. Since the code is not shared in all cases at runtime, it's important to understand where various changes should go during development so that they end up in the appropriate places at build or check-in time.
+For each mode the Updater supports - Web and command line - a dedicated artifact is generated. However, all common operations are located
+in shared code. Since the code is not shared in all cases at runtime, it's important to understand where various changes should go during
+development so that they end up in the appropriate places at build or check-in time.
Changes should be made to the following places in the `updater` repo:
* Shared aspects of the Updater:
- - `/Makefile`
- - `make updater.phar`
- - `make index.php`
- `/lib/LogException.php`
- - `/lib/RecursiveDirectoryIteratorWithoutDate.php`
- `/lib/UpdateException.php`
- `/lib/Updater.php` <-- core of the Updater functionality for both Web and CLI modes is implemented here
* Aspects specific to the Web Updater:
- `/index.web.php`
- - `/Makefile`
- - `make index.php`
* Aspects specific to the CLI Updater:
- `/updater.php`
- `/buildVersionFile.php`
- `/lib/CommandApplication.php`
- `/lib/UpdateCommand.php`
- - `/Makefile`
- - `make updater.phar`
+
+#### Build system (`/Makefile`)
+
+The Makefile contains targets for both modes. The relevant targets are:
+
+* `make updater.phar` — builds the CLI Updater artifact
+* `make index.php` — builds the Web Updater artifact
+* `make check-same-code-base` — verifies that `index.php` contains the same shared library code as the individual files in `/lib/` <-- checked by CI
+
+**How `index.php` is built:** The Web Updater runs as a single self-contained PHP file. `make index.php` produces it by *concatenating* `index.web.php`
+with the shared library files (`UpdateException.php`, `LogException.php`, `Updater.php`), stripping `namespace`/`use` statements and duplicate PHP
+opening tags via `awk`/`grep`. This means that after changing any shared `/lib/*.php` file or `index.web.php`, you must re-run `make index.php` and
+check in the resulting `/index.php`.
+
+**How `updater.phar` is built:** `make updater.phar` generates a transient `/lib/Version.php` (via `buildVersionFile.php`), runs `composer dump-autoload`,
+then uses [box](https://github.com/box-project/box) (configured in `/box.json`) to package `/updater.php`, `/lib/*.php`, and `/vendor/*.php` into the
+phar. The transient `Version.php` is deleted after the build. After changing any CLI-specific or shared file, you must re-run `make updater.phar` and
+check in the resulting `/updater.phar`.
+
+#### Build configuration files
+
+* `/box.json` — Configures which files are packaged into `updater.phar` (the `lib/` directory, vendor PHP files, with `updater.php` as the main entry point)
+* `/composer.json` / `/composer.lock` — Manage PHP dependencies; the CLI Updater bootstraps via `vendor/autoload.php`
+* `/vendor/` — Checked in to the repo (used at runtime by the CLI Updater and during the phar build)
#### Server components
@@ -239,15 +257,35 @@ Keep in mind that for the update/upgrade process there are some additional compo
- Handles database upgrade migrations
- Handles app updates (i.e. for compatibility with a new major version of Server)
-### Dependences needed for building
+### Dependencies needed for building
#### box
Install box: https://github.com/box-project/box/blob/main/doc/installation.md#composer
+#### Composer
+
+A working `composer` install is required both for dependency management and for building. See "Testing" below for version requirements.
+
#### Tests
-If you want to run the tests locally, you'll need to run them in an environment that has Nextcloud's required PHP modules installed. The various test scenarios are all available via the `make test*` (see Makefile for specifics).
+If you want to run the tests locally, you'll need to run them in an environment that has Nextcloud's required PHP modules installed.
+Tests use [Behat](https://docs.behat.org/) (a BDD framework); feature files live under `tests/features/`. Test data (gitignored) is
+generated under `tests/data/`.
+
+The available test targets are:
+
+| Target | Description |
+|---|---|
+| `make test` | Runs all Behat feature tests |
+| `make test-cli` | Runs only the CLI updater tests (`features/cli.feature`) |
+| `make test-stable26` | Tests update path for stable26 |
+| `make test-stable27` | Tests update path for stable27 |
+| `make test-stable28` | Tests update path for stable28 |
+| `make test-master` | Tests update path for master |
+| `make test-user.ini` | Tests `.user.ini` handling (`features/user.ini.feature`) |
+| `make check-same-code-base` | Verifies `/index.php` is in sync with `/lib/*.php` + `/index.web.php` |
+| `make build-and-local-test` | Builds the phar then runs a local updater smoke test |
### Build artifacts / What to check in
@@ -269,16 +307,23 @@ Plus whatever has been changed in the implementation in:
#### Transient
-Used during the build process but not checked in:
+Used during the build process but not checked in (gitignored):
* Specific to the CLI Updater:
- - `/lib/Version.php`
+ - `/lib/Version.php` — auto-generated by `buildVersionFile.php`, deleted after `make updater.phar` completes
### Testing
-#### Check same code base test keeps failing
+#### `check-same-code-base` test keeps failing
+
+This test (`make check-same-code-base`) runs `tests/checkSameCodeBase.php`, which verifies that the contents of each shared library file in `/lib/`
+(excluding CLI-only files `CommandApplication.php` and `UpdateCommand.php`) are present verbatim inside the built `/index.php`. If it fails, it
+usually means:
-If it keeps failing on your PR, confirm your local version of `composer` is the same version in-use in the workflow runner. You can check the details of the test run and find the version currently being used (and therefore required locally) under "Setup Tools". (Hint: distro versions are typically too outdated. Remove that version and see https://getcomposer.org/download/ to install your own version).
+1. **You changed a file in `/lib/` but didn't rebuild `index.php`.** Run `make index.php` and commit the result.
+2. **Your local `composer` version differs from CI.** Confirm your local version of `composer` is the same version in-use in the workflow runner.
+ You can check the details of the test run and find the version currently being used (and therefore required locally) under "Setup Tools". (Hint:
+ distro versions are typically too outdated. Remove that version and see https://getcomposer.org/download/ to install your own version).
#### CI
diff --git a/REUSE.toml b/REUSE.toml
index ccd18bd4..6a863295 100644
--- a/REUSE.toml
+++ b/REUSE.toml
@@ -24,7 +24,7 @@ SPDX-FileCopyrightText = "2020 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "MIT"
[[annotations]]
-path = ["vendor-bin/box/**", "vendor-bin/psalm/**", "vendor-bin/rector/**"]
+path = ["vendor-bin/box/**", "vendor-bin/phpunit/**", "vendor-bin/psalm/**", "vendor-bin/rector/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2023 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"
diff --git a/composer.json b/composer.json
index d05b7108..85dba635 100644
--- a/composer.json
+++ b/composer.json
@@ -26,7 +26,8 @@
"psalm": "psalm --threads=$(nproc)",
"psalm:ci": "psalm --threads=1",
"psalm:fix": "- --issues=InvalidReturnType,InvalidNullableReturnType,MissingParamType,InvalidFalsableReturnType",
- "rector": "rector && composer run cs:fix"
+ "rector": "rector && composer run cs:fix",
+ "test:unit": "phpunit -c phpunit.xml"
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8"
diff --git a/index.php b/index.php
index 277bfa7e..44259871 100644
--- a/index.php
+++ b/index.php
@@ -81,7 +81,12 @@ public function __construct(
throw new \Exception('Could not read data directory from config.php.');
}
- $versionFileName = $this->nextcloudDir . '/version.php';
+ $versionFileName = $this->buildPath('version.php');
+ // Invalidate version.php OPcache entry to assure file_exists() returns
+ // false after files were removed while opcache.enable_file_override is set.
+ if (function_exists('opcache_invalidate')) {
+ opcache_invalidate($versionFileName, true);
+ }
if (!file_exists($versionFileName)) {
// fallback to version in config.php
$version = $this->getConfigOptionString('version');
@@ -111,6 +116,17 @@ public function __construct(
$this->buildTime = $buildTime;
}
+ /**
+ * Builds an absolute path by joining the Nextcloud root directory with the provided relative path.
+ * Handles leading and trailing slashes to prevent double-slash issues.
+ *
+ * @param string $suffix Relative path to append (with or without leading slash)
+ * @return string The absolute path
+ */
+ public function buildPath(string $suffix): string {
+ return rtrim($this->nextcloudDir, '/') . '/' . ltrim($suffix, '/');
+ }
+
/**
* @return array{array, string}
*/
@@ -121,7 +137,7 @@ private function readConfigFile(): array {
throw new \Exception('Configuration not found in ' . $dir);
}
} else {
- $configFileName = $this->nextcloudDir . '/config/config.php';
+ $configFileName = $this->buildPath('config/config.php');
}
if (!file_exists($configFileName)) {
@@ -335,8 +351,8 @@ private function getAppDirectories(): array {
throw new \Exception('Invalid configuration in apps_paths configuration key');
}
- if (str_starts_with($appsPath['path'], $this->nextcloudDir . '/')) {
- $relativePath = substr($appsPath['path'], strlen($this->nextcloudDir . '/'));
+ if (str_starts_with($appsPath['path'], $this->buildPath(''))) {
+ $relativePath = substr($appsPath['path'], strlen($this->buildPath('')));
if ($relativePath !== 'apps') {
$expected[] = $relativePath;
}
@@ -436,13 +452,13 @@ public function checkWritePermissions(): void {
}
// Special handling for included default theme
- foreach ($this->getRecursiveDirectoryIterator($this->nextcloudDir . '/themes/example', $excludedElements) as $fileInfo) {
+ foreach ($this->getRecursiveDirectoryIterator($this->buildPath('themes/example'), $excludedElements) as $fileInfo) {
if (!$fileInfo->isWritable()) {
$notWritablePaths[] = $fileInfo->getFilename();
}
}
- $themesReadmeFileInfo = new \SplFileInfo($this->nextcloudDir . '/themes/README');
+ $themesReadmeFileInfo = new \SplFileInfo($this->buildPath('themes/README'));
if (!$themesReadmeFileInfo->isWritable()) {
$notWritablePaths[] = $themesReadmeFileInfo->getFilename();
}
@@ -502,14 +518,14 @@ public function createBackup(): void {
}
foreach ($this->getRecursiveDirectoryIterator($this->nextcloudDir, $excludedElements) as $absolutePath => $fileInfo) {
- $relativePath = explode($this->nextcloudDir, $absolutePath)[1];
+ $relativePath = ltrim(substr($absolutePath, strlen($this->nextcloudDir)), '/');
$relativeDirectory = dirname($relativePath);
// Create folder if it doesn't exist
- if (!file_exists($backupFolderLocation . '/' . $relativeDirectory)) {
- $state = mkdir($backupFolderLocation . '/' . $relativeDirectory, 0750, true);
+ if (!file_exists($backupFolderLocation . $relativeDirectory)) {
+ $state = mkdir($backupFolderLocation . $relativeDirectory, 0750, true);
if ($state === false) {
- throw new \Exception('Could not create folder: ' . $backupFolderLocation . '/' . $relativeDirectory);
+ throw new \Exception('Could not create folder: ' . $backupFolderLocation . $relativeDirectory);
}
}
@@ -574,28 +590,64 @@ private function getUpdateServerResponse(): array {
$updateURL = $updaterServer . '?version=' . str_replace('.', 'x', $this->getConfigOptionMandatoryString('version')) . 'xxx' . $releaseChannel . 'xx' . urlencode($this->buildTime) . 'x' . PHP_MAJOR_VERSION . 'x' . PHP_MINOR_VERSION . 'x' . PHP_RELEASE_VERSION;
$this->silentLog('[info] updateURL: ' . $updateURL);
+ $maxRetries = 2;
+
+ for ($attempt = 1; $attempt <= $maxRetries; $attempt++) {
+ try {
+ return $this->fetchUpdateServerResponse($updateURL);
+ } catch (\Exception $e) {
+ $lastException = $e;
+ $this->silentLog('[warn] attempt ' . $attempt . '/' . $maxRetries . ' failed: ' . $e->getMessage());
+ if ($attempt < $maxRetries) {
+ sleep(1);
+ }
+ }
+ }
+
+ throw $lastException;
+ }
+
+ /**
+ * @throws \Exception
+ */
+ private function fetchUpdateServerResponse(string $updateURL): array {
// Download update response
$curl = $this->getCurl($updateURL);
+ curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 10);
+ curl_setopt($curl, CURLOPT_TIMEOUT, 30);
/** @var false|string $response */
$response = curl_exec($curl);
+
if ($response === false) {
- throw new \Exception('Could not do request to updater server: ' . curl_error($curl));
+ $curlError = curl_error($curl);
+ curl_close($curl);
+ throw new \Exception('Could not do request to updater server: ' . $curlError);
}
+ $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE);
curl_close($curl);
+ if ($httpCode !== 200 && $httpCode !== 204) {
+ $this->silentLog('[warn] update server returned HTTP ' . $httpCode);
+ throw new \Exception('Update server returned unexpected HTTP status ' . $httpCode);
+ }
+
// Response can be empty when no update is available
if ($response === '') {
return [];
}
libxml_use_internal_errors(true);
- $xml = simplexml_load_string($response);
- if ($xml === false) {
- $content = strlen($response) > 200 ? substr($response, 0, 200) . '…' : $response;
- $errors = implode("\n", array_map(fn ($error) => $error->message, libxml_get_errors()));
- throw new \Exception('Could not parse updater server XML response: ' . $content . "\nErrors:\n" . $errors);
+ try {
+ $xml = simplexml_load_string($response);
+ if ($xml === false) {
+ $content = strlen($response) > 200 ? substr($response, 0, 200) . '…' : $response;
+ $errors = implode("\n", array_map(fn ($error) => $error->message, libxml_get_errors()));
+ throw new \Exception('Could not parse updater server XML response: ' . $content . "\nErrors:\n" . $errors);
+ }
+ } finally {
+ libxml_clear_errors();
}
$response = get_object_vars($xml);
@@ -615,11 +667,11 @@ private function getUpdateServerResponse(): array {
*
* @throws \Exception
*/
- public function downloadUpdate(string $url = '', ?Closure $downloadProgress = null): void {
+ public function downloadUpdate(string $urlOverride = '', ?Closure $downloadProgress = null): void {
$this->silentLog('[info] downloadUpdate()');
$this->downloadProgress = $downloadProgress;
- $downloadURLs = $url !== '' ? [$url] : $this->getDownloadURLs();
+ $downloadURLs = $urlOverride !== '' ? [$urlOverride] : $this->getDownloadURLs();
$this->silentLog('[info] will try to download archive from: ' . implode(', ', $downloadURLs));
@@ -641,10 +693,10 @@ public function downloadUpdate(string $url = '', ?Closure $downloadProgress = nu
}
}
- foreach ($downloadURLs as $url) {
+ foreach ($downloadURLs as $urlOverride) {
$this->previousProgress = 0;
- $saveLocation = $storageLocation . basename((string)$url);
- if ($this->downloadArchive($url, $saveLocation)) {
+ $saveLocation = $storageLocation . basename((string)$urlOverride);
+ if ($this->downloadArchive($urlOverride, $saveLocation)) {
return;
}
}
@@ -830,7 +882,7 @@ private function getDownloadedFilePath(): string {
*
* @throws \Exception
*/
- public function verifyIntegrity(string $urlOverride = ''): void {
+ public function verifyIntegrity(string $urlOverride = '', string $signature = ''): void {
$this->silentLog('[info] verifyIntegrity()');
if ($this->getCurrentReleaseChannel() === 'daily') {
@@ -838,18 +890,13 @@ public function verifyIntegrity(string $urlOverride = ''): void {
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');
- }
-
- if (!is_string($response['signature'])) {
- throw new \Exception('Signature specified for defined update should be a string');
+ if ($signature === '') {
+ if ($urlOverride !== '') {
+ throw new \Exception(
+ 'Custom download url provided. You need to provide a signature with --signature or skip integrity check with --no-verify.'
+ );
+ }
+ $signature = $this->getSignatureFromUpdater();
}
$certificate = << To login you need to provide the unhashed value of "updater.secret" in your config file. If you don't know that value, you can access this updater directly via the Nextcloud admin screen or generate
your own secret:php -r '$password = trim(shell_exec("openssl rand -base64 48"));if(strlen($password) === 64) {$hash = password_hash($password, PASSWORD_DEFAULT) . "\n"; echo "Insert as \"updater.secret\": ".$hash; echo "The plaintext value is: ".$password."\n";}else{echo "Could not execute OpenSSL.\n";};'
+ php -r '$password = trim(shell_exec("openssl rand -base64 48"));
+ if (strlen($password) === 64) {
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ echo "Insert this line in your config.php:\n\n";
+ echo "'\''updater.secret'\'' => '\''" . $hash . "'\'',\n\n";
+ echo "The plaintext value is: " . $password . "\n";
+ } else {
+ echo "Could not execute OpenSSL.\n";
+ }'
If you don't know that value, you can access this updater directly via the Nextcloud admin screen or generate your own secret:
-php -r '$password = trim(shell_exec("openssl rand -base64 48"));if(strlen($password) === 64) {$hash = password_hash($password, PASSWORD_DEFAULT) . "\n"; echo "Insert as \"updater.secret\": ".$hash; echo "The plaintext value is: ".$password."\n";}else{echo "Could not execute OpenSSL.\n";};'
+ php -r '$password = trim(shell_exec("openssl rand -base64 48"));
+ if (strlen($password) === 64) {
+ $hash = password_hash($password, PASSWORD_DEFAULT);
+ echo "Insert this line in your config.php:\n\n";
+ echo "'\''updater.secret'\'' => '\''" . $hash . "'\'',\n\n";
+ echo "The plaintext value is: " . $password . "\n";
+ } else {
+ echo "Could not execute OpenSSL.\n";
+ }'