From 4494b7409fcbe8a0689feb02cdfcf15e9d2a7b81 Mon Sep 17 00:00:00 2001 From: upstream Date: Thu, 24 Sep 2026 18:46:25 +0200 Subject: [PATCH] Upstream pelican/panel state of the touched files before 53ea227d1f --- app/Services/Helpers/PluginService.php | 594 ++++++++++++++++++ .../Services/PluginServiceTest.php | 422 +++++++++++++ 2 files changed, 1016 insertions(+) create mode 100644 app/Services/Helpers/PluginService.php create mode 100644 tests/Integration/Services/PluginServiceTest.php diff --git a/app/Services/Helpers/PluginService.php b/app/Services/Helpers/PluginService.php new file mode 100644 index 0000000..f794155 --- /dev/null +++ b/app/Services/Helpers/PluginService.php @@ -0,0 +1,594 @@ +app->runningUnitTests()) { + return; + } + + /** @var ClassLoader $classLoader */ + $classLoader = File::getRequire(base_path('vendor/autoload.php')); + + $plugins = Plugin::orderBy('load_order')->get(); + foreach ($plugins as $plugin) { + try { + // Filter out plugins that require a newer plugin api than this panel supports + // (only in production, so plugin developers can work on fixing their plugin locally) + if ($this->app->isProduction() && !$plugin->isApiVersionSupported()) { + $this->setStatus($plugin, PluginStatus::Incompatible, 'This Plugin requires plugin api version ' . $plugin->effectiveApiVersion() . ' but this Panel only supports up to version ' . Plugin::SUPPORTED_API_VERSION . '!'); + + continue; + } + + // Filter out plugins that are not compatible with the current panel version + if (!$plugin->isCompatible()) { + $this->setStatus($plugin, PluginStatus::Incompatible, 'This Plugin is only compatible with Panel version ' . $plugin->panel_version . (!$plugin->isPanelVersionStrict() ? ' or a compatible newer version' : '') . ' but you are using version ' . $this->app->make(SoftwareVersionService::class)->currentPanelVersion() . '!'); + + continue; + } else { + // Make sure to update the status if a plugin is no longer incompatible (e.g. because the user changed their panel version) + if ($plugin->status === PluginStatus::Incompatible) { + $this->disablePlugin($plugin); + } + } + + if ($plugin->namespace === 'Error') { + continue; + } + + // Always autoload src directory to make sure all class names can be resolved (e.g. in migrations) + $namespace = $plugin->namespace . '\\'; + if (!array_key_exists($namespace, $classLoader->getPrefixesPsr4())) { + $classLoader->setPsr4($namespace, plugin_path($plugin->id, 'src/')); + + $classLoader->addPsr4('Database\Factories\\', plugin_path($plugin->id, 'database/Factories/')); + $classLoader->addPsr4('Database\Seeders\\', plugin_path($plugin->id, 'database/Seeders/')); + } + + // Load config + $config = plugin_path($plugin->id, 'config', $plugin->id . '.php'); + if (file_exists($config)) { + config()->set($plugin->id, require $config); + } + + // Filter out plugins that should not be loaded (e.g. because they are disabled or not installed yet) + if (!$plugin->shouldLoad()) { + continue; + } + + // Load translations + $translations = plugin_path($plugin->id, 'lang'); + if (file_exists($translations)) { + $this->app->afterResolving('translator', function ($translator) use ($plugin, $translations) { + if ($plugin->isLanguage()) { + $translator->addPath($translations); + } else { + $translator->addNamespace($plugin->id, $translations); + } + }); + } + + // Register service providers + foreach ($plugin->getProviders() as $provider) { + if (!class_exists($provider) || !is_subclass_of($provider, ServiceProvider::class)) { + continue; + } + + $this->app->register($provider); + } + + // Resolve artisan commands + foreach ($plugin->getCommands() as $command) { + if (!class_exists($command) || !is_subclass_of($command, Command::class)) { + continue; + } + + ConsoleApplication::starting(function ($artisan) use ($command) { + $artisan->resolve($command); + }); + } + + // Load migrations + $migrations = plugin_path($plugin->id, 'database', 'migrations'); + if (file_exists($migrations)) { + $this->app->afterResolving('migrator', function ($migrator) use ($migrations) { + $migrator->path($migrations); + }); + } + + // Load views + $views = plugin_path($plugin->id, 'resources', 'views'); + if (file_exists($views)) { + $this->app->afterResolving('view', function ($view) use ($plugin, $views) { + $view->addNamespace($plugin->id, $views); + }); + } + } catch (Exception $exception) { + $this->handlePluginException($plugin, $exception); + } + } + } + + public function loadPanelPlugins(Panel $panel): void + { + // Don't load any plugins during tests + if ($this->app->runningUnitTests()) { + return; + } + + $plugins = Plugin::orderBy('load_order')->get(); + foreach ($plugins as $plugin) { + try { + if (!$plugin->shouldLoad($panel->getId())) { + continue; + } + + $pluginClass = $plugin->fullClass(); + + throw_unless(class_exists($pluginClass), new Exception('Class "' . $pluginClass . '" not found')); + + $panel->plugin(new $pluginClass()); + + if ($plugin->status === PluginStatus::Errored) { + $this->enablePlugin($plugin); + } + } catch (Exception $exception) { + $this->handlePluginException($plugin, $exception); + } + } + } + + /** + * @param null|array $newPackages + * @param null|array $oldPackages + * + * @throws Exception + */ + public function manageComposerPackages(?array $newPackages = [], ?array $oldPackages = null): void + { + $newPackages ??= []; + + $plugins = Plugin::orderBy('load_order')->get(); + foreach ($plugins as $plugin) { + if (!$plugin->composer_packages) { + continue; + } + + if (!$plugin->shouldLoad()) { + continue; + } + + try { + $pluginPackages = json_decode($plugin->composer_packages, true, 512, JSON_THROW_ON_ERROR); + + $newPackages = array_merge($newPackages, $pluginPackages); + } catch (Exception $exception) { + report($exception); + } + } + + $oldPackages = collect($oldPackages) + ->filter(fn ($version, $package) => !array_key_exists($package, $newPackages)) + ->keys() + ->unique() + ->toArray(); + + if (count($oldPackages) > 0) { + $result = Process::path(base_path())->timeout(600)->run(['composer', 'remove', ...$oldPackages]); + throw_if($result->failed(), new Exception('Could not remove old composer packages: ' . $result->errorOutput())); + } + + $newPackages = collect($newPackages) + ->map(fn ($version, $package) => "$package:$version") + ->flatten() + ->unique() + ->toArray(); + + if (count($newPackages) > 0) { + $result = Process::path(base_path())->timeout(600)->run(['composer', 'require', ...$newPackages]); + throw_if($result->failed(), new Exception('Could not require new composer packages: ' . $result->errorOutput())); + } + } + + /** @throws Exception */ + public function runPluginMigrations(Plugin $plugin): void + { + $migrations = plugin_path($plugin->id, 'database', 'migrations'); + if (file_exists($migrations)) { + try { + $migrator = $this->app->make(Migrator::class); + $migrator->run($migrations); + } catch (Exception $exception) { + throw new Exception("Could not run migrations': " . $exception->getMessage()); + } + } + } + + /** @throws Exception */ + public function rollbackPluginMigrations(Plugin $plugin): void + { + $migrations = plugin_path($plugin->id, 'database', 'migrations'); + if (file_exists($migrations)) { + try { + $migrator = $this->app->make(Migrator::class); + $migrator->reset($migrations); + } catch (Exception $exception) { + throw new Exception("Could not rollback migrations': " . $exception->getMessage()); + } + } + } + + /** @throws Exception */ + public function runPluginSeeder(Plugin $plugin): void + { + $seeder = $plugin->getSeeder(); + if ($seeder) { + try { + $seederObject = $this->app->make($seeder)->setContainer($this->app); + + Model::unguarded(fn () => $seederObject->__invoke()); + } catch (Exception $exception) { + throw new Exception('Could not run seeder: ' . $exception->getMessage()); + } + } + } + + public function buildAssets(bool $throw = false): bool + { + try { + $result = Process::path(base_path())->timeout(300)->run('yarn install'); + throw_if($result->failed(), new Exception('Could not install yarn dependencies: ' . $result->errorOutput())); + + $result = Process::path(base_path())->timeout(600)->run('yarn build'); + throw_if($result->failed(), new Exception('Could not build assets: ' . $result->errorOutput())); + + return true; + } catch (Exception $exception) { + throw_if($throw || $this->isDevModeActive(), ($exception)); + + Log::warning($exception->getMessage(), ['exception' => $exception]); + } + + return false; + } + + /** @throws Exception */ + public function installPlugin(Plugin $plugin, bool $enable = true): void + { + try { + $this->manageComposerPackages(json_decode($plugin->composer_packages, true, 512)); + + $this->buildAssets($plugin->isTheme()); + + $this->runPluginMigrations($plugin); + + $this->runPluginSeeder($plugin); + + if ($enable) { + $this->enablePlugin($plugin); + } else { + if ($plugin->status === PluginStatus::NotInstalled) { + $this->disablePlugin($plugin); + } + } + + foreach (Filament::getPanels() as $panel) { + $panel->clearCachedComponents(); + } + } catch (Exception $exception) { + $this->setStatus($plugin, PluginStatus::NotInstalled, $exception->getMessage()); + + throw $exception; + } + } + + /** @throws Exception */ + public function updatePlugin(Plugin $plugin): void + { + $downloadUrl = $plugin->getDownloadUrlForUpdate(); + throw_unless($downloadUrl, new Exception('No download url found.')); + + $this->downloadPluginFromUrl($downloadUrl, $plugin->id); + + Plugin::refreshRows(); + $plugin = $plugin->refresh(); + + $this->installPlugin($plugin, false); + + cache()->forget("plugins.$plugin->id.update"); + } + + /** @throws Exception */ + public function uninstallPlugin(Plugin $plugin, bool $deleteFiles = false): void + { + $pluginPackages = json_decode($plugin->composer_packages, true, 512); + + $this->rollbackPluginMigrations($plugin); + + $this->setStatus($plugin, PluginStatus::NotInstalled); + + if ($deleteFiles) { + $this->deletePlugin($plugin); + } + + $this->buildAssets(); + + $this->manageComposerPackages(oldPackages: $pluginPackages); + + foreach (Filament::getPanels() as $panel) { + $panel->clearCachedComponents(); + } + } + + /** @throws Exception */ + public function downloadPluginFromFile(UploadedFile $file, ?string $expectedId = null): string + { + // Validate file size to prevent zip bombs + $maxSize = config('panel.plugin.max_import_size'); + $maxSizeLabel = round($maxSize / 1024 / 1024, 2); + throw_if($file->getSize() > $maxSize, new Exception("Zip file too large. ($maxSizeLabel MiB)")); + + $zip = new ZipArchive(); + + throw_unless($zip->open($file->getPathname()), new Exception('Could not open zip file.')); + + // The check above only sees the compressed bytes, which say nothing about what the + // archive expands to — a small, highly compressible zip can still fill the plugins + // volume. Total the uncompressed entry sizes while walking the entries and bail as + // soon as they exceed the same limit, before anything is written to disk. + $uncompressedSize = 0; + + // Validate zip contents before extraction + for ($i = 0; $i < $zip->numFiles; $i++) { + $filename = $zip->getNameIndex($i); + if (Str::contains($filename, '..') || Str::startsWith($filename, '/')) { + $zip->close(); + throw new Exception('Zip file contains invalid path traversal sequences.'); + } + + $stat = $zip->statIndex($i) ?: []; + $uncompressedSize += $stat['size'] ?? 0; + + if ($uncompressedSize > $maxSize) { + $zip->close(); + throw new Exception("Zip file contents too large. ($maxSizeLabel MiB)"); + } + } + + // Extract into a temporary directory that lives on the same filesystem as the plugins + // directory (named as a dotfile so plugin discovery ignores it). Deriving the id from + // the extracted plugin.json — rather than the upload filename — means a renamed zip + // still imports, and keeping temp + target on one volume makes every move below an + // atomic same-filesystem rename instead of a cross-device copy that can fail. + $tmpDir = (new TemporaryDirectory(base_path('plugins'))) + ->name('.import-' . Str::random(16)) + ->deleteWhenDestroyed() + ->create(); + + $extractPath = $tmpDir->path('contents'); + + if (!$zip->extractTo($extractPath)) { + $zip->close(); + throw new Exception('Could not extract zip file.'); + } + + $zip->close(); + + // Locate plugin.json, either at the archive root (flat zip) or one level deep + // (single top-level folder). Prefer the shallowest match. + $manifest = $this->locatePluginManifest($extractPath); + throw_if($manifest === null, new Exception(trans('admin/plugin.notifications.import_no_manifest'))); + + $data = File::json($manifest, JSON_THROW_ON_ERROR); + $id = $data['id'] ?? null; + throw_if(!is_string($id) || trim($id) === '', new Exception(trans('admin/plugin.notifications.import_no_manifest'))); + + // The id becomes the install directory name and must equal it once installed (see + // Plugin::getRows()), so guard against anything that isn't a safe slug — e.g. an id of + // "../../evil" that would escape the plugins directory when passed to plugin_path(). + $pluginName = Str::lower(trim($id)); + throw_unless(preg_match('/^[a-z0-9][a-z0-9._-]*$/', $pluginName), new Exception(trans('admin/plugin.notifications.import_invalid_id'))); + + // When updating a known plugin, the archive must be for that same plugin — reject a + // mismatched id before moving anything, so an update can't overwrite a different plugin. + throw_if($expectedId !== null && $pluginName !== $expectedId, new Exception(trans('admin/plugin.notifications.import_id_mismatch', ['expected' => $expectedId, 'actual' => $pluginName]))); + + $target = plugin_path($pluginName); + $source = dirname($manifest); + + // Importing over an existing plugin replaces it. Set the current install aside as a + // rollback until the new copy is in place, so a failed move can't leave nothing behind. + // The backup is a dotfile so discovery ignores it during the swap; temp, target and + // backup all sit under plugins/, so each move is a same-filesystem rename. + $rollback = null; + if (File::isDirectory($target)) { + $rollback = plugin_path('.' . $pluginName . '.bak'); + File::deleteDirectory($rollback); + throw_unless(File::moveDirectory($target, $rollback), new Exception('Could not set the existing plugin aside.')); + } + + // Move the folder containing plugin.json to plugins/ so the layout is correct + // regardless of the archive's internal folder name. + if (!File::moveDirectory($source, $target)) { + if ($rollback !== null) { + File::moveDirectory($rollback, $target); + } + + throw new Exception('Could not move plugin into place.'); + } + + if ($rollback !== null) { + File::deleteDirectory($rollback); + } + + return $pluginName; + } + + /** + * Find a plugin.json inside an extracted archive, either at its root or within a single + * top-level directory. Returns the absolute path to the manifest, or null if none exists. + */ + private function locatePluginManifest(string $path): ?string + { + $rootManifest = join_paths($path, 'plugin.json'); + if (File::exists($rootManifest)) { + return $rootManifest; + } + + foreach (File::directories($path) as $directory) { + $manifest = join_paths($directory, 'plugin.json'); + if (File::exists($manifest)) { + return $manifest; + } + } + + return null; + } + + /** @throws Exception */ + public function downloadPluginFromUrl(string $url, ?string $expectedId = null): string + { + $basename = pathinfo($url, PATHINFO_BASENAME); + $tmpDir = TemporaryDirectory::make()->deleteWhenDestroyed(); + $tmpPath = $tmpDir->path($basename); + + $content = Http::timeout(60)->connectTimeout(5)->throw()->get($url)->body(); + + // Validate file size to prevent zip bombs + $maxSize = config('panel.plugin.max_import_size'); + $maxSizeLabel = round($maxSize / 1024 / 1024, 2); + throw_if(strlen($content) > $maxSize, new InvalidFileUploadException("Zip file too large. ($maxSizeLabel MiB)")); + + throw_unless(file_put_contents($tmpPath, $content), new InvalidFileUploadException('Could not write temporary file.')); + + return $this->downloadPluginFromFile(new UploadedFile($tmpPath, $basename, 'application/zip'), $expectedId); + } + + public function deletePlugin(Plugin $plugin): void + { + File::deleteDirectory(plugin_path($plugin->id)); + } + + public function enablePlugin(string|Plugin $plugin): void + { + $this->setStatus($plugin, PluginStatus::Enabled); + } + + public function disablePlugin(string|Plugin $plugin): void + { + $this->setStatus($plugin, PluginStatus::Disabled); + } + + /** @param array $data */ + private function setMetaData(string|Plugin $plugin, array $data): void + { + $path = plugin_path($plugin->id, 'plugin.json'); + + if (File::exists($path)) { + $pluginData = File::json($path, JSON_THROW_ON_ERROR); + $metaData = array_merge($pluginData['meta'] ?? [], $data); + $pluginData['meta'] = $metaData; + + File::put($path, json_encode($pluginData, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); + + $plugin->update($metaData); + } + } + + private function setStatus(string|Plugin $plugin, PluginStatus $status, ?string $message = null): void + { + $this->setMetaData($plugin, [ + 'status' => $status, + 'status_message' => $message, + ]); + } + + /** + * @param array $order + * + * @throws JsonException + */ + public function updateLoadOrder(array $order): void + { + foreach ($order as $i => $plugin) { + $plugin = Plugin::firstOrFail(str($plugin)->lower()->toString()); + + $this->setMetaData($plugin, [ + 'load_order' => $i, + ]); + } + } + + public function hasThemePluginEnabled(): bool + { + $plugins = Plugin::orderBy('load_order')->get(); + foreach ($plugins as $plugin) { + if ($plugin->isTheme() && $plugin->status === PluginStatus::Enabled) { + return true; + } + } + + return false; + } + + /** @return string[] */ + public function getPluginLanguages(): array + { + $languages = []; + + $plugins = Plugin::orderBy('load_order')->get(); + foreach ($plugins as $plugin) { + if ($plugin->status !== PluginStatus::Enabled || !$plugin->isLanguage()) { + continue; + } + + $languages = array_merge($languages, collect(File::directories(plugin_path($plugin->id, 'lang')))->map(fn ($path) => basename($path))->toArray()); + } + + return array_unique($languages); + } + + public function isDevModeActive(): bool + { + return config('panel.plugin.dev_mode', false); + } + + private function handlePluginException(Plugin $plugin, Exception $exception): void + { + throw_if($this->isDevModeActive(), ($exception)); + + report($exception); + + $this->setStatus($plugin, PluginStatus::Errored, $exception->getMessage()); + } +} diff --git a/tests/Integration/Services/PluginServiceTest.php b/tests/Integration/Services/PluginServiceTest.php new file mode 100644 index 0000000..3d0c38f --- /dev/null +++ b/tests/Integration/Services/PluginServiceTest.php @@ -0,0 +1,422 @@ +service = $this->app->make(PluginService::class); + } + + protected function tearDown(): void + { + // Clean up anything the service touched in the real plugins directory: installed + // plugins, their rollback backups, and any leftover extraction working directories. + foreach ($this->importedPlugins as $id) { + File::deleteDirectory(plugin_path($id)); + File::deleteDirectory(plugin_path('.' . $id . '.bak')); + } + + foreach (File::glob(plugin_path('.import-*')) as $leftover) { + File::deleteDirectory($leftover); + } + + // TemporaryDirectory::make() does not clean up on its own; remove the upload fixtures. + foreach ($this->uploadDirs as $dir) { + $dir->delete(); + } + + parent::tearDown(); + } + + public function test_import_uses_plugin_json_id_not_the_upload_filename(): void + { + // Zip contains a top-level folder "test-import-plugin", but the upload is renamed as + // a browser would on a duplicate download. + $file = $this->makeUpload('test-import-plugin(1).zip', [ + 'test-import-plugin/plugin.json' => $this->manifest('test-import-plugin'), + ]); + + $this->importedPlugins[] = 'test-import-plugin'; + + $id = $this->service->downloadPluginFromFile($file); + + $this->assertSame('test-import-plugin', $id); + $this->assertFileExists(plugin_path('test-import-plugin', 'plugin.json')); + // The (1) suffixed folder must never be created. + $this->assertDirectoryDoesNotExist(plugin_path('test-import-plugin(1)')); + } + + public function test_import_handles_flat_zip_without_a_top_level_folder(): void + { + $file = $this->makeUpload('renamed(2).zip', [ + 'plugin.json' => $this->manifest('test-flat-plugin'), + ]); + + $this->importedPlugins[] = 'test-flat-plugin'; + + $id = $this->service->downloadPluginFromFile($file); + + $this->assertSame('test-flat-plugin', $id); + $this->assertFileExists(plugin_path('test-flat-plugin', 'plugin.json')); + } + + public function test_import_lowercases_the_manifest_id(): void + { + $file = $this->makeUpload('Test-Upper.zip', [ + 'Test-Upper/plugin.json' => $this->manifest('Test-Upper'), + ]); + + $this->importedPlugins[] = 'test-upper'; + + $id = $this->service->downloadPluginFromFile($file); + + $this->assertSame('test-upper', $id); + $this->assertFileExists(plugin_path('test-upper', 'plugin.json')); + } + + public function test_import_cleans_up_its_extraction_directory(): void + { + $this->importedPlugins[] = 'test-cleanup-plugin'; + + $this->service->downloadPluginFromFile($this->makeUpload('test-cleanup-plugin.zip', [ + 'test-cleanup-plugin/plugin.json' => $this->manifest('test-cleanup-plugin'), + ])); + + // The temporary ".import-*" working directory must not linger under plugins/. + $this->assertSame([], File::glob(plugin_path('.import-*'))); + } + + public function test_import_without_a_manifest_fails_loudly(): void + { + $file = $this->makeUpload('not-a-plugin.zip', [ + 'readme.txt' => 'nothing to see here', + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest')); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_with_a_non_string_id_fails_loudly(): void + { + $file = $this->makeUpload('bad-id.zip', [ + 'plugin.json' => json_encode(['id' => []]), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest')); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_with_a_missing_id_fails_loudly(): void + { + $file = $this->makeUpload('no-id.zip', [ + 'plugin.json' => json_encode(['name' => 'No Id']), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest')); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_with_an_empty_id_fails_loudly(): void + { + $file = $this->makeUpload('empty-id.zip', [ + 'plugin.json' => json_encode(['id' => '']), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest')); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_ignores_a_plugin_json_nested_too_deep(): void + { + // locatePluginManifest only looks at the root and one level deep; anything deeper is + // not treated as a plugin. + $file = $this->makeUpload('deep.zip', [ + 'a/b/plugin.json' => $this->manifest('too-deep'), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage(trans('admin/plugin.notifications.import_no_manifest')); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_rejects_a_zip_with_a_path_traversal_entry(): void + { + // A malicious archive entry that would write outside the extraction directory must be + // rejected before extraction, even if it also carries a valid plugin.json. + $file = $this->makeUpload('evil.zip', [ + '../evil.txt' => 'pwned', + 'evil/plugin.json' => $this->manifest('evil'), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Zip file contains invalid path traversal sequences.'); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_rejects_a_zip_larger_than_the_configured_max(): void + { + config()->set('panel.plugin.max_import_size', 10); + + $file = $this->makeUpload('too-big.zip', [ + 'too-big/plugin.json' => $this->manifest('too-big'), + ]); + + $this->expectException(Exception::class); + $this->expectExceptionMessage('Zip file too large'); + + $this->service->downloadPluginFromFile($file); + } + + public function test_import_rejects_a_zip_that_expands_beyond_the_configured_max(): void + { + // Clean up in case a regression lets the archive through and it actually installs. + $this->importedPlugins[] = 'zip-bomb'; + + config()->set('panel.plugin.max_import_size', 4096); + + $file = $this->makeUpload('zip-bomb.zip', [ + 'zip-bomb/plugin.json' => $this->manifest('zip-bomb'), + 'zip-bomb/payload.txt' => str_repeat('a', 100_000), + ]); + + // The archive compresses down small enough to clear the compressed-size check, so only + // the uncompressed total catches it. + $this->assertLessThan(4096, $file->getSize()); + + $thrown = null; + + try { + $this->service->downloadPluginFromFile($file); + } catch (Exception $exception) { + $thrown = $exception; + } + + $this->assertNotNull($thrown, 'Expected the oversized archive to be rejected.'); + $this->assertStringContainsString('Zip file contents too large', $thrown->getMessage()); + + // Nothing should have been written: no install, and no leftover extraction directory. + $this->assertDirectoryDoesNotExist(plugin_path('zip-bomb')); + $this->assertSame([], File::glob(plugin_path('.import-*'))); + } + + public function test_import_from_url_downloads_and_installs_the_plugin(): void + { + $this->importedPlugins[] = 'test-url-plugin'; + + $bytes = file_get_contents($this->makeUpload('whatever.zip', [ + 'test-url-plugin/plugin.json' => $this->manifest('test-url-plugin'), + ])->getPathname()); + + Http::fake(['*' => Http::response($bytes)]); + + $id = $this->service->downloadPluginFromUrl('https://example.test/download/test-url-plugin.zip'); + + $this->assertSame('test-url-plugin', $id); + $this->assertFileExists(plugin_path('test-url-plugin', 'plugin.json')); + } + + /** + * @param string $id an id that is a non-empty string but not a safe directory slug + */ + #[DataProvider('unsafeIds')] + public function test_import_rejects_an_unsafe_plugin_id(string $id): void + { + // A clean entry name (so it passes the archive path-traversal check) whose manifest + // still carries a dangerous id must be rejected before anything is moved into place. + $file = $this->makeUpload('sneaky.zip', [ + 'plugin.json' => json_encode(['id' => $id]), + ]); + + try { + $this->service->downloadPluginFromFile($file); + $this->fail('Expected the import to be rejected for id: ' . $id); + } catch (Exception $e) { + $this->assertSame(trans('admin/plugin.notifications.import_invalid_id'), $e->getMessage()); + } + + // Nothing should have escaped the plugins directory. + $this->assertDirectoryDoesNotExist(base_path('evil')); + $this->assertDirectoryDoesNotExist(base_path('plugins/../evil')); + } + + /** @return array */ + public static function unsafeIds(): array + { + return [ + 'parent traversal' => ['../../evil'], + 'single parent' => ['../evil'], + 'contains slash' => ['foo/bar'], + 'leading dot' => ['.hidden'], + 'just dots' => ['..'], + 'contains space' => ['foo bar'], + ]; + } + + public function test_import_over_an_existing_plugin_replaces_it(): void + { + $this->importedPlugins[] = 'test-replace-plugin'; + + $this->service->downloadPluginFromFile($this->makeUpload('test-replace-plugin.zip', [ + 'test-replace-plugin/plugin.json' => $this->manifest('test-replace-plugin', '1.0.0'), + ])); + + $this->assertSame('1.0.0', $this->installedVersion('test-replace-plugin')); + + // Re-importing the same id is allowed and replaces the existing install. + $id = $this->service->downloadPluginFromFile($this->makeUpload('test-replace-plugin.zip', [ + 'test-replace-plugin/plugin.json' => $this->manifest('test-replace-plugin', '2.0.0'), + ])); + + // The new copy is in place and no rollback backup is left behind. + $this->assertSame('test-replace-plugin', $id); + $this->assertSame('2.0.0', $this->installedVersion('test-replace-plugin')); + $this->assertDirectoryDoesNotExist(plugin_path('.test-replace-plugin.bak')); + } + + public function test_import_succeeds_when_the_id_matches_the_expected_id(): void + { + $this->importedPlugins[] = 'test-expected-plugin'; + + $id = $this->service->downloadPluginFromFile($this->makeUpload('test-expected-plugin.zip', [ + 'test-expected-plugin/plugin.json' => $this->manifest('test-expected-plugin'), + ]), 'test-expected-plugin'); + + $this->assertSame('test-expected-plugin', $id); + $this->assertFileExists(plugin_path('test-expected-plugin', 'plugin.json')); + } + + public function test_import_rejects_an_archive_for_a_different_plugin_than_expected(): void + { + // Stand in for an update whose archive actually claims to be a *different* plugin than + // the one being updated. It must be rejected before anything is written, so the update + // can neither reinstall the wrong plugin nor overwrite an unrelated one. + $this->importedPlugins[] = 'test-target-plugin'; + $this->importedPlugins[] = 'test-other-plugin'; + + $this->service->downloadPluginFromFile($this->makeUpload('test-target-plugin.zip', [ + 'test-target-plugin/plugin.json' => $this->manifest('test-target-plugin', '1.0.0'), + ])); + + $file = $this->makeUpload('update.zip', [ + 'test-other-plugin/plugin.json' => $this->manifest('test-other-plugin', '9.9.9'), + ]); + + try { + $this->service->downloadPluginFromFile($file, 'test-target-plugin'); + $this->fail('Expected a mismatched update to be rejected.'); + } catch (Exception $e) { + $this->assertSame( + trans('admin/plugin.notifications.import_id_mismatch', ['expected' => 'test-target-plugin', 'actual' => 'test-other-plugin']), + $e->getMessage() + ); + } + + // The intended plugin is untouched and the archive's plugin was never written. + $this->assertSame('1.0.0', $this->installedVersion('test-target-plugin')); + $this->assertDirectoryDoesNotExist(plugin_path('test-other-plugin')); + } + + public function test_import_keeps_the_existing_plugin_when_the_move_fails(): void + { + $this->importedPlugins[] = 'test-clean-plugin'; + + $this->service->downloadPluginFromFile($this->makeUpload('test-clean-plugin.zip', [ + 'test-clean-plugin/plugin.json' => $this->manifest('test-clean-plugin', '1.0.0'), + ])); + + // Force only the move into plugins/ to fail, standing in for a filesystem-level + // failure mid-replace. The set-aside and restore moves (to/from the .bak dir) run for + // real so we can prove the original install is restored intact. + $real = new Filesystem(); + File::partialMock() + ->shouldReceive('moveDirectory') + ->andReturnUsing(function ($from, $to) use ($real) { + if (str_ends_with($to, '.bak') || str_ends_with($from, '.bak')) { + return $real->moveDirectory($from, $to); + } + + return false; + }); + + $file = $this->makeUpload('test-clean-plugin.zip', [ + 'test-clean-plugin/plugin.json' => $this->manifest('test-clean-plugin', '2.0.0'), + ]); + + try { + $this->service->downloadPluginFromFile($file); + $this->fail('Expected the import to fail.'); + } catch (Exception $e) { + $this->assertSame('Could not move plugin into place.', $e->getMessage()); + } + + // The original 1.0.0 install must survive — not the failed 2.0.0 replacement — and the + // backup directory must be gone. + $this->assertSame('1.0.0', $this->installedVersion('test-clean-plugin')); + $this->assertDirectoryDoesNotExist(plugin_path('.test-clean-plugin.bak')); + } + + private function installedVersion(string $id): string + { + return File::json(plugin_path($id, 'plugin.json'))['version']; + } + + private function manifest(string $id, string $version = '1.0.0'): string + { + return json_encode(['id' => $id, 'version' => $version]); + } + + /** + * Build a zip on disk from the given [path => contents] map and wrap it in an UploadedFile. + * + * @param array $entries + */ + private function makeUpload(string $clientName, array $entries): UploadedFile + { + $tmpDir = TemporaryDirectory::make(); + $this->uploadDirs[] = $tmpDir; + $zipPath = $tmpDir->path('upload.zip'); + + $zip = new ZipArchive(); + $zip->open($zipPath, ZipArchive::CREATE); + foreach ($entries as $path => $contents) { + $zip->addFromString($path, $contents); + } + $zip->close(); + + // test mode (last arg) bypasses the is_uploaded_file() check. + return new UploadedFile($zipPath, $clientName, 'application/zip', null, true); + } +}