Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .github/workflows/ci-quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,19 @@ jobs:
CI_STRICT_TESTS: '1'
run: bash scripts/verify-schema.sh

# Exercise the plugin ZIP-update path against a real DB. The synthetic
# test covers install→update on a disposable plugin; the all-bundled test
# updates EVERY registered bundled plugin in place and restores it, so a
# broken update path surfaces here before it reaches an operator's admin
# UI. Runs after the schema gate so that gate sees pristine plugin data;
# the all-bundled test restores every directory and plugins-row version.
- name: PHP plugin ZIP-update integration tests
env:
CI_STRICT_TESTS: '1'
run: |
php tests/plugin-zip-update.integration.php
php tests/plugin-zip-update-all-bundled.integration.php

- name: Shell test — bin/setup-permissions.sh
run: bash tests/setup-permissions.test.sh

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,7 @@ tests/*
!tests/*.spec.js
!tests/*.config.js
!tests/*.unit.php
!tests/*.integration.php
!tests/*.test.sh
!tests/ci-playwright-policy.json
!tests/seeds/
Expand Down
241 changes: 218 additions & 23 deletions app/Support/PluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -738,15 +738,15 @@ public function installFromZip(string $zipPath): array
$pluginJsonContent = $zip->getFromName($pluginJsonPath);
$pluginMeta = json_decode($pluginJsonContent, true);

if (!$pluginMeta) {
if (!is_array($pluginMeta)) {
$zip->close();
return ['success' => false, 'message' => __('File plugin.json non valido.'), 'plugin_id' => null];
}

// Validate required fields
$requiredFields = ['name', 'display_name', 'version', 'main_file'];
foreach ($requiredFields as $field) {
if (empty($pluginMeta[$field])) {
if (!isset($pluginMeta[$field]) || !is_string($pluginMeta[$field]) || trim($pluginMeta[$field]) === '') {
$zip->close();
return ['success' => false, 'message' => __('Campo obbligatorio mancante: %s', $field), 'plugin_id' => null];
}
Expand All @@ -757,12 +757,10 @@ public function installFromZip(string $zipPath): array
return ['success' => false, 'message' => __('Nome plugin non valido. Usa solo lettere, numeri, trattini o underscore.'), 'plugin_id' => null];
}

// Check if plugin already exists
// An uploaded package with an existing name is an in-place update. Its
// database ID deliberately remains stable, so plugin settings, data and
// hook rows continue to point to the same plugin after the update.
$existingPlugin = $this->getPluginByName($pluginMeta['name']);
if ($existingPlugin) {
$zip->close();
return ['success' => false, 'message' => __('Plugin già installato.'), 'plugin_id' => null];
}

// Check PHP version compatibility
if (!empty($pluginMeta['requires_php'])) {
Expand All @@ -782,27 +780,37 @@ public function installFromZip(string $zipPath): array
return ['success' => false, 'message' => $appCompatibilityError, 'plugin_id' => null];
}

// Extract plugin to storage/plugins directory
// Extract into a sibling staging directory first. Never touch the
// installed copy until the whole archive has been validated, so a bad
// update cannot leave an otherwise working plugin half-extracted.
$pluginsBaseDir = realpath($this->pluginsDir) ?: $this->pluginsDir;
$pluginPath = $pluginsBaseDir . '/' . $pluginMeta['name'];
$targetDirectory = $existingPlugin !== null
? (string) ($existingPlugin['path'] ?? '')
: (string) $pluginMeta['name'];

if (is_dir($pluginPath)) {
if (!$this->isSafePluginDirectoryName($targetDirectory)) {
$zip->close();
return ['success' => false, 'message' => __('Percorso di installazione del plugin non valido.'), 'plugin_id' => null];
}

$pluginPath = $pluginsBaseDir . '/' . $targetDirectory;
if ($existingPlugin === null && is_dir($pluginPath)) {
$zip->close();
return ['success' => false, 'message' => __('Directory plugin già esistente.'), 'plugin_id' => null];
}

if (!mkdir($pluginPath, 0755, true)) {
$stagingPath = $this->createPluginStagingDirectory($pluginsBaseDir, $targetDirectory);
if ($stagingPath === null) {
$zip->close();
return ['success' => false, 'message' => __('Impossibile creare la directory del plugin.'), 'plugin_id' => null];
return ['success' => false, 'message' => __('Impossibile creare la directory temporanea del plugin.'), 'plugin_id' => null];
}

$pluginRealPath = realpath($pluginPath);
$pluginRealPath = realpath($stagingPath);
if ($pluginRealPath === false || strpos($pluginRealPath, rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR)) !== 0) {
$zip->close();
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Percorso di installazione del plugin non valido.'), 'plugin_id' => null];
}
$pluginPath = $pluginRealPath;

$extractedFiles = false;
$pluginRootPrefix = $pluginRootDir ? rtrim($pluginRootDir, '/') . '/' : null;
Expand Down Expand Up @@ -832,14 +840,14 @@ public function installFromZip(string $zipPath): array

if ($targetPath === null) {
$zip->close();
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Il pacchetto contiene percorsi non validi.'), 'plugin_id' => null];
}

if (str_ends_with($filename, '/')) {
if (!is_dir($targetPath) && !mkdir($targetPath, 0755, true)) {
$zip->close();
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Impossibile creare la struttura del plugin.'), 'plugin_id' => null];
}
continue;
Expand All @@ -848,14 +856,14 @@ public function installFromZip(string $zipPath): array
$dir = dirname($targetPath);
if (!is_dir($dir) && !mkdir($dir, 0755, true)) {
$zip->close();
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Impossibile creare la struttura del plugin.'), 'plugin_id' => null];
}

$content = $zip->getFromIndex($i);
if ($content === false || file_put_contents($targetPath, $content) === false) {
$zip->close();
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Errore durante l\'estrazione del plugin.'), 'plugin_id' => null];
}

Expand All @@ -865,17 +873,36 @@ public function installFromZip(string $zipPath): array
$zip->close();

if (!$extractedFiles) {
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Il pacchetto non contiene file validi.'), 'plugin_id' => null];
}

// Verify main file exists
$mainFilePath = $pluginPath . '/' . $pluginMeta['main_file'];
if (!$this->isSafePluginFilePath((string) $pluginMeta['main_file'])) {
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('File principale del plugin non valido.'), 'plugin_id' => null];
}
$mainFilePath = $pluginRealPath . '/' . $pluginMeta['main_file'];
if (!file_exists($mainFilePath)) {
$this->deleteDirectory($pluginPath);
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('File principale del plugin non trovato.'), 'plugin_id' => null];
}

if ($existingPlugin !== null) {
return $this->updatePluginFromStaging(
$existingPlugin,
$pluginMeta,
$stagingPath,
$pluginsBaseDir
);
}

if (!rename($pluginRealPath, $pluginPath)) {
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Impossibile finalizzare l\'installazione del plugin.'), 'plugin_id' => null];
}
$pluginPath = realpath($pluginPath) ?: $pluginPath;

// Insert plugin into database
$stmt = $this->db->prepare("
INSERT INTO plugins (
Expand All @@ -890,7 +917,7 @@ public function installFromZip(string $zipPath): array
// row exists. Clean up before returning.
$this->deleteDirectory($pluginPath);
SecureLogger::error('[PluginManager] Failed to prepare plugin INSERT', [
'plugin' => $pluginMeta['name'] ?? 'unknown',
'plugin' => $pluginMeta['name'],
'db_error' => $this->db->error,
]);
return [
Expand Down Expand Up @@ -1228,6 +1255,174 @@ private function getPluginClassName(string $pluginName): string
return $className . 'Plugin';
}

/**
* Replace the on-disk package and its metadata without changing the plugin
* identity. Settings, plugin_data, logs and hooks all refer to the existing
* ID through foreign keys and are therefore intentionally left untouched.
*
* Filesystem changes are rollback-safe: the old package is kept as a sibling
* backup until the metadata update succeeds.
*
* @param array<string,mixed> $existingPlugin
* @param array<string,mixed> $pluginMeta
* @return array{success:bool,message:string,plugin_id:int|null,updated?:bool}
*/
private function updatePluginFromStaging(
array $existingPlugin,
array $pluginMeta,
string $stagingPath,
string $pluginsBaseDir
): array {
$pluginId = (int) ($existingPlugin['id'] ?? 0);
$directory = (string) ($existingPlugin['path'] ?? '');
if ($pluginId <= 0 || !$this->isSafePluginDirectoryName($directory)) {
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Plugin installato non valido.'), 'plugin_id' => null];
}

try {
$metadata = json_encode($pluginMeta['metadata'] ?? [], JSON_THROW_ON_ERROR);
} catch (\JsonException $e) {
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Metadati del plugin non validi.'), 'plugin_id' => null];
}

$pluginPath = rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . $directory;
try {
$backupPath = rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . '.' . $directory . '.backup-' . bin2hex(random_bytes(8));
} catch (\Throwable $e) {
$this->deleteDirectory($stagingPath);
return ['success' => false, 'message' => __('Impossibile preparare l\'aggiornamento del plugin.'), 'plugin_id' => null];
}
$hasBackup = false;
$newPackageInstalled = false;

try {
if (file_exists($pluginPath) && !is_dir($pluginPath)) {
throw new \RuntimeException('Il percorso del plugin esistente non è una directory.');
}

if (is_dir($pluginPath)) {
if (!rename($pluginPath, $backupPath)) {
throw new \RuntimeException('Impossibile preparare il backup del plugin esistente.');
}
$hasBackup = true;
}

if (!rename($stagingPath, $pluginPath)) {
throw new \RuntimeException('Impossibile sostituire i file del plugin.');
}
$newPackageInstalled = true;

$displayName = (string) $pluginMeta['display_name'];
$description = (string) ($pluginMeta['description'] ?? '');
$version = (string) $pluginMeta['version'];
$author = (string) ($pluginMeta['author'] ?? '');
$authorUrl = (string) ($pluginMeta['author_url'] ?? '');
$pluginUrl = (string) ($pluginMeta['plugin_url'] ?? '');
$mainFile = (string) $pluginMeta['main_file'];
$requiresPhp = (string) ($pluginMeta['requires_php'] ?? '');
$requiresApp = (string) ($pluginMeta['requires_app'] ?? '');

$stmt = $this->db->prepare(
'UPDATE plugins SET display_name = ?, description = ?, version = ?, author = ?, author_url = ?, '
. 'plugin_url = ?, main_file = ?, requires_php = ?, requires_app = ?, metadata = ? WHERE id = ?'
);
if ($stmt === false) {
throw new \RuntimeException('Impossibile aggiornare i metadati del plugin.');
}
$stmt->bind_param(
'ssssssssssi',
$displayName,
$description,
$version,
$author,
$authorUrl,
$pluginUrl,
$mainFile,
$requiresPhp,
$requiresApp,
$metadata,
$pluginId
);
$updated = $stmt->execute();
$stmt->close();
if (!$updated) {
throw new \RuntimeException('Impossibile salvare i metadati aggiornati del plugin.');
}

if ($hasBackup && !$this->deleteDirectory($backupPath)) {
SecureLogger::warning('[PluginManager] Updated plugin backup could not be removed', [
'plugin' => $pluginMeta['name'],
'path' => $backupPath,
]);
}

self::clearPluginCache();
SecureLogger::info("[PluginManager] Plugin updated successfully: {$pluginMeta['name']} (ID: $pluginId)");
return [
'success' => true,
'message' => __('Plugin aggiornato con successo.'),
'plugin_id' => $pluginId,
'updated' => true,
];
} catch (\Throwable $e) {
if ($newPackageInstalled && is_dir($pluginPath)) {
$this->deleteDirectory($pluginPath);
}
if ($hasBackup && is_dir($backupPath) && !rename($backupPath, $pluginPath)) {
SecureLogger::error('[PluginManager] Failed to restore plugin after update rollback', [
'plugin' => $pluginMeta['name'],
'path' => $backupPath,
]);
}
if (is_dir($stagingPath)) {
$this->deleteDirectory($stagingPath);
}
SecureLogger::error('[PluginManager] Plugin update failed', [
'plugin' => $pluginMeta['name'],
'error' => $e->getMessage(),
]);
return [
'success' => false,
'message' => __('Errore durante l\'aggiornamento del plugin: %s', $e->getMessage()),
'plugin_id' => null,
];
}
}

private function createPluginStagingDirectory(string $pluginsBaseDir, string $directory): ?string
{
try {
$path = rtrim($pluginsBaseDir, DIRECTORY_SEPARATOR)
. DIRECTORY_SEPARATOR . '.' . $directory . '.staging-' . bin2hex(random_bytes(8));
} catch (\Throwable $e) {
return null;
}

return mkdir($path, 0755, true) ? $path : null;
}

private function isSafePluginDirectoryName(string $directory): bool
{
return (bool) preg_match('/^[A-Za-z0-9_-]+$/D', $directory);
}

private function isSafePluginFilePath(string $path): bool
{
$path = str_replace('\\', '/', $path);
if ($path === '' || str_contains($path, "\0") || preg_match('#^(?:[A-Za-z]:)?/#', $path)) {
return false;
}
foreach (explode('/', $path) as $segment) {
if ($segment === '' || $segment === '.' || $segment === '..') {
return false;
}
}
return true;
}

/**
* Resolve a ZIP entry path inside the plugin directory and prevent traversal
*/
Expand Down
10 changes: 10 additions & 0 deletions tests/plugin-manager.unit.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,16 @@
$hasWarn = $source !== false && str_contains($source, 'Schema/hook self-heal skipped');
$check($hasWarn, 'same-version failure is non-fatal (warning, no rethrow)');

echo "\nPlugin ZIP update lifecycle:\n";
$check($source !== false && str_contains($source, 'updatePluginFromStaging('), 'existing plugin ZIPs use the update path');
$check($source !== false && str_contains($source, 'createPluginStagingDirectory('), 'ZIPs are extracted to a staging directory first');
$check($source !== false && str_contains($source, "rename(\$pluginPath, \$backupPath)"), 'existing package is backed up before replacement');
$check($source !== false && str_contains($source, "rename(\$stagingPath, \$pluginPath)"), 'staging package is atomically promoted');
$check($source !== false && str_contains($source, 'Failed to restore plugin after update rollback'), 'failed update restores the prior package');
$check($source !== false && str_contains($source, 'UPDATE plugins SET display_name'), 'update persists new manifest metadata');
$check($source !== false && str_contains($source, "'updated' => true"), 'update response identifies a successful update');
$check($source !== false && str_contains($source, 'isSafePluginFilePath'), 'manifest main_file is validated against traversal');

echo "\n================================\n";
echo "Passed: $passed Failed: $failed\n";
exit($failed > 0 ? 1 : 0);
Loading