diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3795b00a7e..d42bcd12f6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -503,7 +503,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php }} - extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, intl, gd, exif, iconv, pgsql, pdo_pgsql + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, intl, gd, exif, iconv, pgsql, pdo_pgsql, sodium ini-values: upload_tmp_dir=${{ runner.temp }}, sys_temp_dir=${{ runner.temp }} coverage: none diff --git a/phpBB/composer.json b/phpBB/composer.json index c77ab77819..321365c140 100644 --- a/phpBB/composer.json +++ b/phpBB/composer.json @@ -28,7 +28,9 @@ "require": { "php": "^8.1", "ext-pdo": "*", + "ext-zip": "*", "ext-zlib": "*", + "ext-sodium": "*", "bantu/ini-get-wrapper": "~1.0", "carlos-mg89/oauth": "^0.8.15", "chita/topological_sort": "^3.0", diff --git a/phpBB/composer.lock b/phpBB/composer.lock index bf9ac175e9..8be1f6e409 100644 --- a/phpBB/composer.lock +++ b/phpBB/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2ce7bc4e8f61d065ff471afa38f12394", + "content-hash": "96d8bdaa91db532b0a0bf5e1b6c0ec31", "packages": [ { "name": "bantu/ini-get-wrapper", @@ -10176,7 +10176,9 @@ "platform": { "php": "^8.1", "ext-pdo": "*", - "ext-zlib": "*" + "ext-zip": "*", + "ext-zlib": "*", + "ext-sodium": "*" }, "platform-dev": [], "platform-overrides": { diff --git a/phpBB/config/default/container/parameters.yml b/phpBB/config/default/container/parameters.yml index 8fcb401914..29502123ed 100644 --- a/phpBB/config/default/container/parameters.yml +++ b/phpBB/config/default/container/parameters.yml @@ -20,3 +20,5 @@ parameters: - passwords.driver.bcrypt - passwords.driver.salted_md5 - passwords.driver.phpass + + packages.public_key: 'auJX0pGetfYatE7t/rX5hAkCLZv9s78TwKkLfR3YGuQ=' diff --git a/phpBB/config/default/container/services.yml b/phpBB/config/default/container/services.yml index c09f21b5c6..ecf8f3109e 100644 --- a/phpBB/config/default/container/services.yml +++ b/phpBB/config/default/container/services.yml @@ -37,6 +37,7 @@ imports: - { resource: services_twig.yml } - { resource: services_twig_extensions.yml } - { resource: services_ucp.yml } + - { resource: services_updater.yml } - { resource: services_user.yml } - { resource: tables.yml } diff --git a/phpBB/config/default/container/services_updater.yml b/phpBB/config/default/container/services_updater.yml new file mode 100644 index 0000000000..918c4de407 --- /dev/null +++ b/phpBB/config/default/container/services_updater.yml @@ -0,0 +1,14 @@ +services: + updater.get_updates: + class: phpbb\update\get_updates + arguments: + - '@filesystem' + - '%packages.public_key%' + - '%core.root_path%' + + updater.controller: + class: phpbb\update\controller + arguments: + - '@filesystem' + - '@updater.get_updates' + - '%core.root_path%' diff --git a/phpBB/includes/acp/acp_update.php b/phpBB/includes/acp/acp_update.php index fa3afa6ce3..0c542cad06 100644 --- a/phpBB/includes/acp/acp_update.php +++ b/phpBB/includes/acp/acp_update.php @@ -38,12 +38,32 @@ class acp_update try { $recheck = $request->variable('versioncheck_force', false); + $do_update = $request->variable('do_update', false); + $updates_available = $version_helper->get_update_on_branch($recheck); $upgrades_available = $version_helper->get_suggested_updates(); + $branch = ''; if (!empty($upgrades_available)) { + $branch = array_key_last($upgrades_available); $upgrades_available = array_pop($upgrades_available); } + + if ($do_update && !empty($updates_available)) + { + $updater = $phpbb_container->get('updater.controller'); + $current_version = $config['version']; + $new_version = $upgrades_available['current']; + $download_url = 'https://download.phpbb.com/pub/release/'; + $download_url .= $branch . '/' . $new_version . '/'; + $download_url .= 'phpBB-' . $current_version . '_to_' . $new_version . '.zip'; + $data = $updater->handle( + $download_url + ); + + $response = new \phpbb\json_response(); + $response->send($data); + } } catch (\RuntimeException $e) { diff --git a/phpBB/language/en/install.php b/phpBB/language/en/install.php index 50ec74f933..9a73b76c42 100644 --- a/phpBB/language/en/install.php +++ b/phpBB/language/en/install.php @@ -221,6 +221,13 @@ $lang = array_merge($lang, array(
We noticed that the last update of your phpBB installation hasn’t been completed. Visit the database updater, ensure Update database only is selected and click on Submit. Don\'t forget to delete the "install"-directory after you have updated the database successfully.
', + // Auto update + 'UPDATE_PACKAGE_DOWNLOAD_FAILURE' => 'Failed to download the update package.', + 'UPDATE_SIGNATURE_DOWNLOAD_FAILURE' => 'Failed to download the update package signature.', + 'UPDATE_SIGNATURE_INVALID' => 'The update package is corrupted.', + 'UPDATE_PACKAGE_EXTRACT_FAILURE' => 'Could not extract files from the update package.', + 'UPDATE_FILES_COPY_FAILURE' => 'Could not copy files from the update package.', + // // Server data // diff --git a/phpBB/phpbb/install/module/requirements/task/check_server_environment.php b/phpBB/phpbb/install/module/requirements/task/check_server_environment.php index bb43ed4b2f..9ae953ee8e 100644 --- a/phpBB/phpbb/install/module/requirements/task/check_server_environment.php +++ b/phpBB/phpbb/install/module/requirements/task/check_server_environment.php @@ -96,7 +96,7 @@ class check_server_environment extends \phpbb\install\task_base */ protected function check_php_version() { - if (version_compare(PHP_VERSION, '7.2.0', '<')) + if (version_compare(PHP_VERSION, '8.1.0', '<')) { $this->response_helper->add_error_message('PHP_VERSION_REQD', 'PHP_VERSION_REQD_EXPLAIN'); diff --git a/phpBB/phpbb/update/controller.php b/phpBB/phpbb/update/controller.php new file mode 100644 index 0000000000..cdbc53495b --- /dev/null +++ b/phpBB/phpbb/update/controller.php @@ -0,0 +1,137 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\update; + +use phpbb\filesystem\filesystem_interface; +use phpbb\language\language; + +class controller +{ + /** @var filesystem_interface Filesystem manager */ + private filesystem_interface $filesystem; + + /** @var get_updates Updater class */ + private get_updates $updater; + + /** @var language Translation handler */ + private language $language; + + /** @var string phpBB root path */ + private string $phpbb_root_path; + + /** + * Constructor. + * + * @param filesystem_interface $filesystem + * @param get_updates $updater + * @param language $language + * @param string $phpbb_root_path + */ + public function __construct( + filesystem_interface $filesystem, + get_updates $updater, + language $language, + string $phpbb_root_path) + { + $this->filesystem = $filesystem; + $this->language = $language; + $this->updater = $updater; + $this->phpbb_root_path = $phpbb_root_path; + } + + /** + * Handle requests. + * + * @param string $download The download URL. + * + * @return string[] Unencoded json response. + */ + public function handle(string $download): array + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + $status = ['status' => 'continue']; + if (!$this->filesystem->exists($update_path)) + { + $result = $this->updater->download($download, $update_path); + if (!$result) + { + return $this->error_response('UPDATE_PACKAGE_DOWNLOAD_FAILURE'); + } + + return $status; + } + + if (!$this->filesystem->exists($update_path . '.sig')) + { + $result = $this->updater->download($download . '.sig', $update_path . '.sig'); + if (!$result) + { + return $this->error_response('UPDATE_SIGNATURE_DOWNLOAD_FAILURE'); + } + + return $status; + } + + if (!$this->filesystem->exists($this->phpbb_root_path . 'store/update') || !is_dir($this->phpbb_root_path . 'store/update')) + { + $result = $this->updater->validate($update_path, $update_path . '.sig'); + if (!$result) + { + return $this->error_response('UPDATE_SIGNATURE_INVALID'); + } + + $result = $this->updater->extract($update_path, $this->phpbb_root_path . 'store/update'); + if (!$result) + { + return $this->error_response('UPDATE_PACKAGE_EXTRACT_FAILURE'); + } + + return $status; + } + + if (!$this->filesystem->exists($this->phpbb_root_path . 'install') || !is_dir($this->phpbb_root_path . 'install')) + { + $result = $this->updater->copy($this->phpbb_root_path . 'store/update'); + if (!$result) + { + return $this->error_response('UPDATE_FILES_COPY_FAILURE'); + } + + return $status; + } + + $this->filesystem->remove([ + $this->phpbb_root_path . 'store/update', + $update_path, + $update_path . '.sig' + ]); + + $status['status'] = 'done'; + return $status; + } + + /** + * Create error response + * + * @param string $error_key + * @return array Error response + */ + protected function error_response(string $error_key): array + { + return [ + 'status' => 'error', + 'error' => $this->language->lang($error_key), + ]; + } +} diff --git a/phpBB/phpbb/update/get_updates.php b/phpBB/phpbb/update/get_updates.php new file mode 100644 index 0000000000..7b79000c6d --- /dev/null +++ b/phpBB/phpbb/update/get_updates.php @@ -0,0 +1,175 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +namespace phpbb\update; + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use phpbb\filesystem\exception\filesystem_exception; +use phpbb\filesystem\filesystem_interface; +use SodiumException; +use ZipArchive; + +class get_updates +{ + /** @var filesystem_interface Filesystem manager */ + protected filesystem_interface $filesystem; + + /** @var Client HTTP client */ + protected Client $http_client; + + /** @var ZipArchive Zip extractor */ + protected ZipArchive $zipper; + + /** @var string Public key to verify package */ + protected string $public_key; + + /** @var string phpBB root path */ + private string $phpbb_root_path; + + /** + * Constructor + * + * @param filesystem_interface $filesystem + * @param string $public_key + * @param string $phpbb_root_path + */ + public function __construct( + filesystem_interface $filesystem, + string $public_key, + string $phpbb_root_path) + { + $this->filesystem = $filesystem; + $this->http_client = new Client(); + $this->zipper = new ZipArchive(); + $this->public_key = base64_decode($public_key); + $this->phpbb_root_path = $phpbb_root_path; + } + + /** + * Download the update package. + * + * @param string $url Download link to the update. + * @param string $storage_path Location for the download. + * + * @return bool Whether the download completed successfully. + */ + public function download(string $url, string $storage_path): bool + { + try + { + $this->http_client->request('GET', $url, [ + 'sink' => $storage_path, + 'allow_redirects' => false + ]); + } + catch (GuzzleException) + { + return false; + } + + return true; + } + + /** + * Validate the downloaded file. + * + * @param string $file_path Path to the download. + * @param string $signature_path The signature file. + * + * @return bool Whether the signature is correct or not. + */ + public function validate(string $file_path, string $signature_path): bool + { + if (file_exists($file_path) === false || !is_readable($file_path)) + { + return false; + } + + if (file_exists($signature_path) === false || !is_readable($signature_path)) + { + return false; + } + + $signature = file_get_contents($signature_path); + + $hash = hash_file('sha384', $file_path, true); + if ($hash === false) + { + return false; + } + + $raw_signature = base64_decode($signature, true); + if ($raw_signature === false) + { + return false; + } + + $raw_public_key = base64_decode($this->public_key, true); + if ($raw_public_key === false) + { + return false; + } + + try + { + return sodium_crypto_sign_verify_detached($raw_signature, $hash, $raw_public_key); + } + catch (SodiumException) + { + return false; + } + } + + /** + * Extract the downloaded archive. + * + * @param string $zip_file Path to the archive. + * @param string $to Path to where to extract the archive to. + * + * @return bool Whether the extraction completed successfully. + */ + public function extract(string $zip_file, string $to): bool + { + if ($this->zipper->open($zip_file) === false) + { + return false; + } + + $result = $this->zipper->extractTo($to); + $this->zipper->close(); + + return $result; + } + + /** + * Copy the update package to the root folder. + * + * @param string $src_dir Where to copy from. + * + * @return bool Whether the files were copied successfully. + */ + public function copy(string $src_dir): bool + { + try + { + $this->filesystem->mirror($src_dir, $this->phpbb_root_path); + } + catch (filesystem_exception) + { + return false; + } + + return true; + } +} diff --git a/tests/update/controller_test.php b/tests/update/controller_test.php new file mode 100644 index 0000000000..bba0186b7f --- /dev/null +++ b/tests/update/controller_test.php @@ -0,0 +1,351 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +use phpbb\update\controller; +use phpbb\update\get_updates; +use phpbb\filesystem\filesystem; +use phpbb\language\language; + +class phpbb_update_controller_test extends \phpbb_test_case +{ + private $filesystem; + private $filesystem_mock; + private $updater_mock; + private $language_mock; + private $phpbb_root_path; + + protected function setUp(): void + { + global $phpbb_root_path; + + $this->filesystem = new filesystem(); + $this->filesystem_mock = $this->createMock(filesystem::class); + $this->updater_mock = $this->createMock(get_updates::class); + $this->language_mock = $this->createMock(language::class); + $this->phpbb_root_path = $phpbb_root_path; + } + + protected function tearDown(): void + { + $this->filesystem->remove([ + $this->phpbb_root_path . 'store/update.zip', + $this->phpbb_root_path . 'store/update.zip.sig', + $this->phpbb_root_path . 'store/update', + ]); + } + + public function test_download_fails(): void + { + $this->updater_mock->expects($this->once()) + ->method('download') + ->willReturn(false); + + $this->language_mock->expects($this->once()) + ->method('lang') + ->with('UPDATE_PACKAGE_DOWNLOAD_FAILURE') + ->willReturnArgument(0); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'error', 'error' => 'UPDATE_PACKAGE_DOWNLOAD_FAILURE'], $response); + } + + public function test_download_success(): void + { + $this->updater_mock->expects($this->once()) + ->method('download') + ->willReturn(true); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'continue'], $response); + } + + public function test_download_signature_fails(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', false], + [$this->phpbb_root_path . 'store/update', false], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('download') + ->with('https://example.com/update.zip.sig', $update_path . '.sig') + ->willReturn(false); + + $this->language_mock->expects($this->once()) + ->method('lang') + ->with('UPDATE_SIGNATURE_DOWNLOAD_FAILURE') + ->willReturnArgument(0); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'error', 'error' => 'UPDATE_SIGNATURE_DOWNLOAD_FAILURE'], $response); + } + + public function test_download_signature_success(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', false], + [$this->phpbb_root_path . 'store/update', false], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('download') + ->with('https://example.com/update.zip.sig', $update_path . '.sig') + ->willReturn(true); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'continue'], $response); + } + + public function test_signature_validation_fails(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', true], + [$this->phpbb_root_path . 'store/update', false], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('validate') + ->willReturn(false); + + $this->language_mock->expects($this->once()) + ->method('lang') + ->with('UPDATE_SIGNATURE_INVALID') + ->willReturnArgument(0); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'error', 'error' => 'UPDATE_SIGNATURE_INVALID'], $response); + } + + public function test_extract_fails(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', true], + [$this->phpbb_root_path . 'store/update', false], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('validate') + ->willReturn(true); + + $this->updater_mock->expects($this->once()) + ->method('extract') + ->willReturn(false); + + $this->language_mock->expects($this->once()) + ->method('lang') + ->with('UPDATE_PACKAGE_EXTRACT_FAILURE') + ->willReturnArgument(0); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'error', 'error' => 'UPDATE_PACKAGE_EXTRACT_FAILURE'], $response); + } + + public function test_extract_success(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', true], + [$this->phpbb_root_path . 'store/update', false], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('validate') + ->willReturn(true); + + $this->updater_mock->expects($this->once()) + ->method('extract') + ->willReturn(true); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'continue'], $response); + } + + public function test_copy_fails(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + $this->filesystem->touch($update_path); // Simulate existing update file + $this->filesystem->touch($update_path . '.sig'); // Simulate existing signature file + $this->filesystem->mkdir($this->phpbb_root_path . 'store/update'); + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', true], + [$this->phpbb_root_path . 'store/update', true], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('copy') + ->willReturn(false); + + $this->language_mock->expects($this->once()) + ->method('lang') + ->with('UPDATE_FILES_COPY_FAILURE') + ->willReturnArgument(0); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'error', 'error' => 'UPDATE_FILES_COPY_FAILURE'], $response); + } + + public function test_copy_success(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + $this->filesystem->touch($update_path); // Simulate existing update file + $this->filesystem->touch($update_path . '.sig'); // Simulate existing signature file + $this->filesystem->mkdir($this->phpbb_root_path . 'store/update'); + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', true], + [$this->phpbb_root_path . 'store/update', true], + [$this->phpbb_root_path . 'install', false], + ]); + + $this->updater_mock->expects($this->once()) + ->method('copy') + ->willReturn(true); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'continue'], $response); + } + + public function test_successful_update_process(): void + { + $update_path = $this->phpbb_root_path . 'store/update.zip'; + $signature_path = $update_path . '.sig'; + $update_dir = $this->phpbb_root_path . 'store/update'; + + $this->filesystem->touch($update_path); + $this->filesystem->touch($signature_path); + $this->filesystem->mkdir($update_dir); + + $this->filesystem_mock->expects($this->any()) + ->method('exists') + ->willReturnMap([ + [$update_path, true], + [$update_path . '.sig', true], + [$this->phpbb_root_path . 'store/update', true], + [$this->phpbb_root_path . 'install', true], + ]); + + $this->filesystem_mock->expects($this->once()) + ->method('remove') + ->with([$update_dir, $update_path, $signature_path]); + + $controller = new controller( + $this->filesystem_mock, + $this->updater_mock, + $this->language_mock, + $this->phpbb_root_path + ); + + $response = $controller->handle('https://example.com/update.zip'); + $this->assertEquals(['status' => 'done'], $response); + } +} \ No newline at end of file diff --git a/tests/update/get_updates_test.php b/tests/update/get_updates_test.php new file mode 100644 index 0000000000..b21a53659d --- /dev/null +++ b/tests/update/get_updates_test.php @@ -0,0 +1,320 @@ + + * @license GNU General Public License, version 2 (GPL-2.0) + * + * For full copyright and license information, please see + * the docs/CREDITS.txt file. + * + */ + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\ClientException; +use phpbb\filesystem\exception\filesystem_exception; +use phpbb\filesystem\filesystem_interface; +use phpbb\update\get_updates; + +class phpbb_update_get_updates_test extends phpbb_test_case +{ + private $filesystem; + private $http_client; + private $zipper; + private $update; + private $public_key = 'atest_public_keyatest_public_keyatest_public_keyatest_public_key'; + + private $file_path = __DIR__ . '/../tmp/download.zip'; + + private $signature_path = __DIR__ . '/../tmp/signature.sig'; + + private $phpbb_root_path; + + public function setUp(): void + { + global $phpbb_root_path; + + parent::setUp(); + + $this->filesystem = $this->createMock(filesystem_interface::class); + $this->http_client = $this->createMock(Client::class); + $this->zipper = $this->createMock(ZipArchive::class); + $this->phpbb_root_path = $phpbb_root_path; + + // Set up the `get_updates` instance with injected mocks. + $this->update = new get_updates($this->filesystem, base64_encode($this->public_key), $this->phpbb_root_path); + } + + public function tearDown(): void + { + if (file_exists($this->file_path)) + { + unlink($this->file_path); + } + + if (file_exists($this->signature_path)) + { + unlink($this->signature_path); + } + + parent::tearDown(); + } + + public function test_download_success() + { + $this->http_client->expects($this->once()) + ->method('request') + ->with('GET', 'http://example.com/update.zip', [ + 'sink' => '/path/to/storage', + 'allow_redirects' => false + ]) + ->willReturn(true); + + $client_reflection = new \ReflectionProperty($this->update, 'http_client'); + $client_reflection->setValue($this->update, $this->http_client); + + $result = $this->update->download('http://example.com/update.zip', '/path/to/storage'); + $this->assertTrue($result); + } + + public function test_download_failure() + { + $this->http_client->expects($this->once()) + ->method('request') + ->willReturnCallback(function ($method, $url, $options) + { + throw new ClientException('bad client', new \GuzzleHttp\Psr7\Request($method, $url)); + }); + $client_reflection = new \ReflectionProperty($this->update, 'http_client'); + $client_reflection->setValue($this->update, $this->http_client); + + $result = $this->update->download('http://example.com/update.zip', '/path/to/storage'); + $this->assertFalse($result); + } + + public function test_validate_success() + { + $keypair = sodium_crypto_sign_keypair(); + + $secret_key = sodium_crypto_sign_secretkey($keypair); + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + file_put_contents($this->file_path, 'test file content'); + + $hash = hash_file('sha384', $this->file_path, true); + file_put_contents($this->signature_path, base64_encode(sodium_crypto_sign_detached($hash, $secret_key))); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertTrue($this->update->validate($this->file_path, $this->signature_path)); + } + + public function test_validate_file_not_exist() + { + $file_path = __DIR__ . '/../tmp/download.zip'; + $signature_path = __DIR__ . '/../tmp/signature.sig'; + + $keypair = sodium_crypto_sign_keypair(); + + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($file_path, $signature_path)); + } + + public function test_validate_sig_not_exist() + { + $keypair = sodium_crypto_sign_keypair(); + + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + file_put_contents($this->file_path, 'test file content'); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + } + + public function test_validate_file_not_accessible() + { + if (strtolower(substr(PHP_OS, 0, 3)) === 'win') + { + $this->markTestSkipped('Unable to test unreadable files on Windows'); + } + + $keypair = sodium_crypto_sign_keypair(); + + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + file_put_contents($this->file_path, 'test file content'); + + chmod($this->file_path, 0000); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + + chmod($this->file_path, 0666); + } + + public function test_validate_sig_not_accessible() + { + if (strtolower(substr(PHP_OS, 0, 3)) === 'win') + { + $this->markTestSkipped('Unable to test unreadable files on Windows'); + } + + $keypair = sodium_crypto_sign_keypair(); + + $secret_key = sodium_crypto_sign_secretkey($keypair); + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + file_put_contents($this->file_path, 'test file content'); + + $hash = hash_file('sha384', $this->file_path, true); + file_put_contents($this->signature_path, base64_encode(sodium_crypto_sign_detached($hash, $secret_key))); + + chmod($this->signature_path, 0000); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + + chmod($this->signature_path, 0666); + } + + public function test_validate_sig_not_base64() + { + $keypair = sodium_crypto_sign_keypair(); + + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + file_put_contents($this->file_path, 'test file content'); + + file_put_contents($this->signature_path, 'SGVsbG8gV29ybGQ==='); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + } + + public function test_validate_invalid_pub_key() + { + $keypair = sodium_crypto_sign_keypair(); + + $secret_key = sodium_crypto_sign_secretkey($keypair); + + file_put_contents($this->file_path, 'test file content'); + + $hash = hash_file('sha384', $this->file_path, true); + file_put_contents($this->signature_path, base64_encode(sodium_crypto_sign_detached($hash, $secret_key))); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, '!not!base64'); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + } + + public function test_validate_fail() + { + $keypair = sodium_crypto_sign_keypair(); + + $secret_key = sodium_crypto_sign_secretkey($keypair); + + // Recreate keypair for different public key + $keypair = sodium_crypto_sign_keypair(); + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair)); + + file_put_contents($this->file_path, 'test file content'); + + $hash = hash_file('sha384', $this->file_path, true); + file_put_contents($this->signature_path, base64_encode(sodium_crypto_sign_detached($hash, $secret_key))); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + } + + public function test_validate_invalid_pub_key_length() + { + $keypair = sodium_crypto_sign_keypair(); + + $secret_key = sodium_crypto_sign_secretkey($keypair); + $public_key = base64_encode(sodium_crypto_sign_publickey($keypair) . 'Foo='); + + file_put_contents($this->file_path, 'test file content'); + + $hash = hash_file('sha384', $this->file_path, true); + file_put_contents($this->signature_path, base64_encode(sodium_crypto_sign_detached($hash, $secret_key))); + + $client_reflection = new \ReflectionProperty($this->update, 'public_key'); + $client_reflection->setValue($this->update, $public_key); + + $this->assertFalse($this->update->validate($this->file_path, $this->signature_path)); + } + + public function test_extract_success() + { + $this->zipper->expects($this->once()) + ->method('open') + ->with('/path/to/zipfile.zip') + ->willReturn(true); + + $this->zipper->expects($this->once()) + ->method('extractTo') + ->with('/path/to/extract') + ->willReturn(true); + + $this->zipper->expects($this->once()) + ->method('close'); + + $zipperReflection = new \ReflectionProperty($this->update, 'zipper'); + $zipperReflection->setValue($this->update, $this->zipper); + + $result = $this->update->extract('/path/to/zipfile.zip', '/path/to/extract'); + $this->assertTrue($result); + } + + public function test_extract_failure() + { + $this->zipper->expects($this->once()) + ->method('open') + ->with('/path/to/zipfile.zip') + ->willReturn(false); + + $zipperReflection = new \ReflectionProperty($this->update, 'zipper'); + $zipperReflection->setValue($this->update, $this->zipper); + + $result = $this->update->extract('/path/to/zipfile.zip', '/path/to/extract'); + $this->assertFalse($result); + } + + public function test_copy_success() + { + $this->filesystem->expects($this->once()) + ->method('mirror') + ->with('/source/dir', $this->phpbb_root_path); + + $result = $this->update->copy('/source/dir'); + $this->assertTrue($result); + } + + public function test_copy_failure() + { + $this->filesystem->expects($this->once()) + ->method('mirror') + ->willThrowException(new filesystem_exception()); + + $result = $this->update->copy('/source/dir'); + $this->assertFalse($result); + } +}