From d2f2c6740edd74f293d1d792f1ab3652ed05c1b2 Mon Sep 17 00:00:00 2001 From: Greg Anderson Date: Thu, 20 Jun 2024 10:00:38 -0700 Subject: [PATCH] Fixes #5991: Adds Convert Symlinks Option (#6030) (#6043) * Start with a failing test, to show that we have correctly replicated the problem. * Convert symlinks in the archive to avoid failures in the PharData class. * Move test to its own file. * Remove extraneous test steps in "setup" method of ArchiveTest that are not doing anything not already covered in the archive dump test. * Remove unnecessary logic * Update src/Commands/core/ArchiveDumpCommands.php * Update tests/functional/ArchiveSymlinkTest.php * Update src/Commands/core/ArchiveDumpCommands.php --------- Co-authored-by: Greg Anderson Co-authored-by: Moshe Weitzman --- src/Commands/core/ArchiveDumpCommands.php | 67 +++++++++++++++++++++- tests/functional/ArchiveSymlinkTest.php | 70 +++++++++++++++++++++++ tests/functional/ArchiveTest.php | 40 ------------- 3 files changed, 135 insertions(+), 42 deletions(-) create mode 100644 tests/functional/ArchiveSymlinkTest.php diff --git a/src/Commands/core/ArchiveDumpCommands.php b/src/Commands/core/ArchiveDumpCommands.php index 5887374474..eed14b444c 100644 --- a/src/Commands/core/ArchiveDumpCommands.php +++ b/src/Commands/core/ArchiveDumpCommands.php @@ -67,6 +67,7 @@ final class ArchiveDumpCommands extends DrushCommands #[CLI\Option(name: 'destination', description: 'The full path and filename in which the archive should be stored. Any relative path will be calculated from Drupal root (usually web for drupal/recommended-project projects). If omitted, it will be saved to the configured temp directory.')] #[CLI\Option(name: 'overwrite', description: 'Overwrite destination file if exists.')] #[CLI\Option(name: 'code', description: 'Archive codebase.')] + #[CLI\Option(name: 'convert-symlinks', description: 'Replace all symlinks with copies of the files/directories that they point to. Default is to only convert symlinks that point outside the project root.')] #[CLI\Option(name: 'exclude-code-paths', description: 'Comma-separated list of paths (or regular expressions matching paths) to exclude from the code archive.')] #[CLI\Option(name: 'extra-dump', description: 'Add custom arguments/options to the dumping of the database (e.g. mysqldump command).')] #[CLI\Option(name: 'files', description: 'Archive Drupal files.')] @@ -98,6 +99,7 @@ public function dump(array $options = [ 'generatorversion' => InputOption::VALUE_REQUIRED, 'exclude-code-paths' => InputOption::VALUE_REQUIRED, 'extra-dump' => self::REQ, + 'convert-symlinks' => false, ]): string { $this->prepareArchiveDir(); @@ -129,6 +131,8 @@ public function dump(array $options = [ ]; } + $this->convertSymlinks($options['convert-symlinks']); + return $this->createArchiveFile($components, $options); } @@ -146,9 +150,9 @@ protected function prepareArchiveDir(): void /** * Creates the archive file and returns the absolute path. * - * @param array $archiveComponents + * @param $archiveComponents * The list of components (files) to include into the archive file. - * @param array $options + * @param $options * The command options. * * @return string @@ -169,6 +173,7 @@ private function createArchiveFile(array $archiveComponents, array $options): st $archive = new PharData($archivePath); $this->createManifestFile($options); + $archive->buildFromDirectory($this->archiveDir); $this->logger()->info(dt('Compressing archive...')); @@ -237,6 +242,64 @@ private function createManifestFile(array $options): void ); } + /** + * Converts symlinks to the linked files/folders for an archive. + * + * @param bool $convert_symlinks + * Whether to convert all symlinks. + * + */ + public function convertSymlinks( + bool $convert_symlinks, + ): void { + // If symlinks are disabled, convert symlinks to full content. + $this->logger()->info(dt('Converting symlinks...')); + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->archiveDir), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($iterator as $file) { + if ( + $file->isLink() && ($convert_symlinks || strpos( + $file->getLinkTarget(), + $this->archiveDir + ) !== 0) + ) { + $target = readlink($file->getPathname()); + + if (is_file($target)) { + $content = file_get_contents($target); + unlink($file->getPathname()); + file_put_contents($file->getPathname(), $content); + } elseif (is_dir($target)) { + $path = $file->getPathname(); + unlink($path); + mkdir($path, 0755); + foreach ( + $iterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator( + $target, + \RecursiveDirectoryIterator::SKIP_DOTS + ), + \RecursiveIteratorIterator::SELF_FIRST + ) as $item + ) { + if ($item->isDir()) { + mkdir($path . DIRECTORY_SEPARATOR . $iterator->getSubPathname()); + } else { + copy( + $item->getPathname(), + $path . DIRECTORY_SEPARATOR . $iterator->getSubPathname() + ); + } + } + } + } + } + } + /** * Returns TRUE if the site is a "web" docroot site. * diff --git a/tests/functional/ArchiveSymlinkTest.php b/tests/functional/ArchiveSymlinkTest.php new file mode 100644 index 0000000000..1569c1b7aa --- /dev/null +++ b/tests/functional/ArchiveSymlinkTest.php @@ -0,0 +1,70 @@ +setUpDrupal(1, true); + $this->archiveDumpOptions = [ + 'db' => null, + 'files' => null, + 'code' => null, + 'exclude-code-paths' => 'sut/sites/.+/settings.php,(?!sut|composer\.json|composer\.lock).*', + ]; + + $this->archivePath = Path::join($this->getSandbox(), 'archive.tar.gz'); + + $this->linktarget = Path::join($this->getSandbox(), 'symlinktest.txt'); + $this->linkdestination = Path::join($this->webroot(), 'symlinkdest.txt'); + + file_put_contents($this->linktarget, "This is a symlink target file."); + symlink($this->linktarget, $this->linkdestination); + } + + public function tearDown(): void + { + unlink($this->linktarget); + unlink($this->linkdestination); + } + + public function testArchiveDumpSymlinkReplaceCommand(): void + { + // The symlinks written in setup would cause the PharData class to + // fail if we did not replace them before archiving. + // @see https://github.com/drush-ops/drush/pull/6030 + $this->drush( + ArchiveDumpCommands::DUMP, + [], + array_merge($this->archiveDumpOptions, [ + 'destination' => $this->archivePath, + 'overwrite' => null, + ]) + ); + } +} diff --git a/tests/functional/ArchiveTest.php b/tests/functional/ArchiveTest.php index 53ee292844..3a53f9f7f1 100644 --- a/tests/functional/ArchiveTest.php +++ b/tests/functional/ArchiveTest.php @@ -19,11 +19,7 @@ class ArchiveTest extends CommandUnishTestCase use FSUtils; protected string $archivePath; - protected string $restorePath; - protected string $extractPath; protected array $archiveDumpOptions; - protected array $archiveRestoreOptions; - protected array $fixtureDatabaseSettings; /** * @inheritdoc @@ -39,42 +35,6 @@ public function setUp(): void ]; $this->archivePath = Path::join($this->getSandbox(), 'archive.tar.gz'); - $this->drush( - 'archive:dump', - [], - array_merge($this->archiveDumpOptions, [ - 'destination' => $this->archivePath, - 'overwrite' => null, - ]) - ); - $actualArchivePath = Path::canonicalize($this->getOutput()); - $this->assertEquals($this->archivePath, $actualArchivePath); - - $this->restorePath = Path::join($this->getSandbox(), 'restore'); - $this->removeDir($this->restorePath); - - $this->extractPath = Path::join($this->getSandbox(), 'extract'); - $this->removeDir($this->extractPath); - $archive = new PharData($this->archivePath); - $archive->extractTo($this->extractPath); - - $this->drush( - 'status', - [], - ['format' => 'json'] - ); - $this->fixtureDatabaseSettings = json_decode($this->getOutput(), true); - $this->fixtureDatabaseSettings['db-name'] = 'archive_dump_restore_test_' . mt_rand(); - $dbUrlParts = explode(':', self::getDbUrl()); - $this->fixtureDatabaseSettings['db-password'] = substr($dbUrlParts[2], 0, (int)strpos($dbUrlParts[2], '@')); - $fixtureDbUrl = self::getDbUrl() . '/' . $this->fixtureDatabaseSettings['db-name']; - - $this->archiveRestoreOptions = [ - 'destination-path' => $this->restorePath, - 'overwrite' => null, - 'site-subdir' => 'dev', - 'db-url' => $fixtureDbUrl, - ]; } public function testArchiveDumpCommand(): void