diff --git a/Lib/BackupFile.php b/Lib/BackupFile.php index 43d4645..124d36f 100644 --- a/Lib/BackupFile.php +++ b/Lib/BackupFile.php @@ -20,8 +20,11 @@ namespace FacturaScripts\Plugins\Backup\Lib; use FacturaScripts\Core\Tools; +use FilesystemIterator; +use RecursiveCallbackFilterIterator; use RecursiveDirectoryIterator; use RecursiveIteratorIterator; +use SplFileInfo; use Throwable; use ZipStream\Option\Archive; use ZipStream\ZipStream; @@ -31,6 +34,21 @@ */ class BackupFile { + /** + * Carpetas (relativas a la raíz, con '/' final) que no se incluyen en la copia. + * El '/' final evita excluir otras carpetas con el mismo prefijo. + * + * @var string[] + */ + protected const EXCLUDED_FOLDERS = ['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/']; + + /** + * Genera una copia de seguridad de los archivos en MyFiles/Backups. + * + * @param string $channel Canal de log donde se registran los mensajes. + * + * @return bool + */ public static function generate(string $channel = ''): bool { $folder = Tools::folder('MyFiles', 'Backups'); @@ -41,7 +59,7 @@ public static function generate(string $channel = ''): bool // creamos un archivo $file_path = Tools::folder('MyFiles', 'Backups', date('Y-m-d_H-i-s') . '.zip'); - if (false === static::zipFolder($file_path)) { + if (false === static::zipFolder($file_path, $channel)) { Tools::log($channel)->error('record-save-error'); return false; } @@ -54,7 +72,17 @@ public static function generate(string $channel = ''): bool return true; } - protected static function zipFolder(string $fileName): bool + /** + * Comprime la carpeta de FacturaScripts (o la indicada) en un zip, saltando las carpetas + * excluidas sin entrar en ellas y las carpetas o archivos que no se puedan leer. + * + * @param string $fileName Ruta del zip de destino. + * @param string $channel Canal de log donde se registran los errores. + * @param string|null $sourceFolder Carpeta a comprimir. Por defecto, FS_FOLDER. + * + * @return bool + */ + protected static function zipFolder(string $fileName, string $channel = '', ?string $sourceFolder = null): bool { // abrimos un stream de escritura hacia el archivo destino $outputStream = fopen($fileName, 'wb'); @@ -79,9 +107,33 @@ protected static function zipFolder(string $fileName): bool ); } + $root = str_replace(DIRECTORY_SEPARATOR, '/', rtrim($sourceFolder ?? FS_FOLDER, '/\\')); + + // SKIP_DOTS evita visitar '.' y '..' (que con open_basedir provoca un error) + $directory = new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS); + + // podamos las carpetas excluidas sin entrar en ellas, y saltamos lo que no se pueda leer + $filtered = new RecursiveCallbackFilterIterator( + $directory, + function (SplFileInfo $current) use ($root): bool { + if (false === is_readable($current->getPathname())) { + return false; + } + + if ($current->isDir()) { + $relative = substr(str_replace(DIRECTORY_SEPARATOR, '/', $current->getPathname()), strlen($root) + 1); + return false === in_array($relative . '/', static::EXCLUDED_FOLDERS, true); + } + + return true; + } + ); + + // CATCH_GET_CHILD salta las subcarpetas que lancen excepción al abrirlas $files = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator(FS_FOLDER), - RecursiveIteratorIterator::LEAVES_ONLY + $filtered, + RecursiveIteratorIterator::LEAVES_ONLY, + RecursiveIteratorIterator::CATCH_GET_CHILD ); foreach ($files as $file) { @@ -89,22 +141,14 @@ protected static function zipFolder(string $fileName): bool continue; } - $filePath = $file->getRealPath(); - $relativePath = str_replace(DIRECTORY_SEPARATOR, '/', substr($filePath, strlen(FS_FOLDER) + 1)); - - // excluimos algunas carpetas (con '/' final para no excluir otras con el mismo prefijo) - $exclude = ['MyFiles/Backups/', 'MyFiles/Cache/', 'MyFiles/Tmp/', 'Dinamic/']; - foreach ($exclude as $folder) { - if (strpos($relativePath, $folder) === 0) { - continue 2; - } - } - + $filePath = $file->getPathname(); + $relativePath = substr(str_replace(DIRECTORY_SEPARATOR, '/', $filePath), strlen($root) + 1); $zip->addFileFromPath($relativePath, $filePath); } $zip->finish(); } catch (Throwable $e) { + Tools::log($channel)->error('backup-files-error', ['%error%' => $e->getMessage()]); fclose($outputStream); return false; } diff --git a/Test/main/BackupFileUnreadableFoldersTest.php b/Test/main/BackupFileUnreadableFoldersTest.php new file mode 100644 index 0000000..70d2e70 --- /dev/null +++ b/Test/main/BackupFileUnreadableFoldersTest.php @@ -0,0 +1,151 @@ + + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see . + */ + +namespace FacturaScripts\Test\Plugins; + +use FacturaScripts\Plugins\Backup\Lib\BackupFile; +use PHPUnit\Framework\TestCase; +use ReflectionMethod; +use ZipArchive; + +/** + * Comprueba que la copia de archivos poda las carpetas excluidas y salta las que no se pueden leer. + * + * @author Esteban Sánchez Martínez + */ +final class BackupFileUnreadableFoldersTest extends TestCase +{ + /** @var string Carpeta temporal que hace de raíz de FacturaScripts. */ + private string $root = ''; + + /** @var string Ruta del zip generado. */ + private string $zipPath = ''; + + public static function setUpBeforeClass(): void + { + // sin el autoloader del vendor del plugin no se encuentra ZipStream + if (false === class_exists(\ZipStream\ZipStream::class)) { + require_once FS_FOLDER . '/Plugins/Backup/vendor/autoload.php'; + } + } + + protected function setUp(): void + { + $base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'backup_test_' . uniqid(); + $this->root = $base . DIRECTORY_SEPARATOR . 'root'; + $this->zipPath = $base . DIRECTORY_SEPARATOR . 'out.zip'; + + // estructura: archivos normales, carpetas excluidas y una carpeta sin permisos + $files = [ + 'visible.txt', + 'sub/visible2.txt', + 'MyFiles/user.txt', + 'MyFiles/Backups/old.zip', + 'MyFiles/Cache/cache.txt', + 'MyFiles/Tmp/tmp.txt', + 'MyFilesExtra/keep.txt', + 'Dinamic/dinamic.php', + 'locked/secret.txt', + ]; + foreach ($files as $file) { + $path = $this->root . '/' . $file; + mkdir(dirname($path), 0777, true); + file_put_contents($path, 'contenido ' . $file); + } + } + + protected function tearDown(): void + { + // restauramos los permisos para poder borrar la carpeta bloqueada + @chmod($this->root . '/locked', 0777); + + $base = dirname($this->root); + if (is_dir($base)) { + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($base, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + foreach ($items as $item) { + $item->isDir() ? @rmdir($item->getPathname()) : @unlink($item->getPathname()); + } + @rmdir($base); + } + } + + public function testExcludedFoldersAreNotZipped(): void + { + $this->assertTrue($this->zipFolder(), 'zipFolder() debería devolver true'); + + $names = $this->zipNames(); + $this->assertContains('visible.txt', $names); + $this->assertContains('sub/visible2.txt', $names); + $this->assertContains('MyFiles/user.txt', $names); + $this->assertContains('MyFilesExtra/keep.txt', $names, 'No debe excluirse una carpeta con el mismo prefijo'); + + foreach ($names as $name) { + $this->assertStringStartsNotWith('MyFiles/Backups/', $name); + $this->assertStringStartsNotWith('MyFiles/Cache/', $name); + $this->assertStringStartsNotWith('MyFiles/Tmp/', $name); + $this->assertStringStartsNotWith('Dinamic/', $name); + } + } + + public function testUnreadableFolderIsSkipped(): void + { + chmod($this->root . '/locked', 0000); + + // en Windows o ejecutando como root chmod no impide leer: no se puede simular + if (is_readable($this->root . '/locked')) { + $this->markTestSkipped('No se pueden quitar los permisos de lectura en este entorno.'); + } + + $this->assertTrue($this->zipFolder(), 'zipFolder() debería devolver true con una carpeta sin permisos'); + + $names = $this->zipNames(); + $this->assertContains('visible.txt', $names); + $this->assertNotContains('locked/secret.txt', $names); + } + + /** + * Ejecuta BackupFile::zipFolder() sobre la carpeta temporal. + */ + private function zipFolder(): bool + { + $method = new ReflectionMethod(BackupFile::class, 'zipFolder'); + $method->setAccessible(true); + return $method->invoke(null, $this->zipPath, 'test', $this->root); + } + + /** + * Devuelve los nombres de las entradas del zip generado. + * + * @return string[] + */ + private function zipNames(): array + { + $zip = new ZipArchive(); + $this->assertTrue($zip->open($this->zipPath), 'No se pudo abrir el zip'); + $names = []; + for ($i = 0; $i < $zip->numFiles; $i++) { + $names[] = $zip->getNameIndex($i); + } + $zip->close(); + return $names; + } +} diff --git a/Translation/es_ES.json b/Translation/es_ES.json index 36a248c..6f9b734 100644 --- a/Translation/es_ES.json +++ b/Translation/es_ES.json @@ -5,6 +5,7 @@ "backup-charset-error": "La codificación de la copia de seguridad es %db-charset%, mientras que en el config.php es %config-charset%. Pulsa el botón cambiar codificación antes de restaurar.", "backup-charset-mixed-warning": "Advertencia: La copia de seguridad contiene tablas con múltiples codificaciones (%charsets%). Tu configuración actual es %config-charset%.", "backup-file-too-big": "El archivo de copia de seguridad es demasiado grande para subirlo al servidor. El tamaño máximo es %size% MB", + "backup-files-error": "Error al comprimir los archivos de la copia de seguridad: %error%", "backup-memory-warning": "Advertencia: la copia de seguridad ocupa %size% MB, pero solamente tienes %memory% MB de memoria RAM disponible. Insuficiente para realizar la copia de seguridad. Como alternativa puedes comprimir la carpeta de FacturaScripts, que es equivalente a una copia de los archivos.", "backup-port-warning": "El puerto de MySQL debe ser el 3306, pero estás usando el %port%", "backup-use-fixer-plugin": "Se recomienda usar el plugin Fixer para corregir las codificaciones de las tablas antes de realizar copias de seguridad.",