diff --git a/features/dist-archive.feature b/features/dist-archive.feature index e5a5132..72cd9d4 100644 --- a/features/dist-archive.feature +++ b/features/dist-archive.feature @@ -224,3 +224,37 @@ Feature: Generate a distribution archive of a project And the wp-content/plugins/hello-world/hello-world.php file should exist And the wp-content/plugins/hello-world/.travis.yml file should not exist And the wp-content/plugins/hello-world/bin directory should not exist + +Scenario: Avoids recursive symlink + Given a WP install in wordpress + And a .distignore file: + """ + wp-content + wordpress + """ + + When I run `mkdir -p wp-content/plugins` + Then STDERR should be empty + + When I run `rm -rf wordpress/wp-content` + Then STDERR should be empty + + When I run `ln -s {RUN_DIR}/wp-content {RUN_DIR}/wordpress/wp-content` + Then STDERR should be empty + + When I run `wp scaffold plugin hello-world --path=wordpress` + Then the wp-content/plugins/hello-world directory should exist + And the wp-content/plugins/hello-world/hello-world.php file should exist + + When I run `mv wp-content/plugins/hello-world/hello-world.php .` + Then STDERR should be empty + + When I run `rm -rf wp-content/plugins/hello-world` + Then STDERR should be empty + + When I run `ln -s {RUN_DIR} {RUN_DIR}/wp-content/plugins/hello-world` + Then STDERR should be empty + And the wp-content/plugins/hello-world/hello-world.php file should exist + + When I run `wp dist-archive . --plugin-dirname=$(basename "{RUN_DIR}")` + Then STDERR should be empty diff --git a/src/Dist_Archive_Command.php b/src/Dist_Archive_Command.php index 024171e..4d7c13b 100644 --- a/src/Dist_Archive_Command.php +++ b/src/Dist_Archive_Command.php @@ -78,7 +78,8 @@ public function __invoke( $args, $assoc_args ) { $maybe_ignored_files = explode( PHP_EOL, file_get_contents( $dist_ignore_path ) ); $ignored_files = array(); - $archive_base = basename( $path ); + $source_base = basename( $path ); + $archive_base = isset( $assoc_args['plugin-dirname'] ) ? rtrim( $assoc_args['plugin-dirname'], '/' ) : $source_base; foreach ( $maybe_ignored_files as $file ) { $file = trim( $file ); if ( 0 === strpos( $file, '#' ) || empty( $file ) ) { @@ -119,7 +120,77 @@ public function __invoke( $args, $assoc_args ) { } } - if ( isset( $assoc_args['plugin-dirname'] ) && rtrim( $assoc_args['plugin-dirname'], '/' ) !== $archive_base ) { + /** + * Given the path to a directory, check are any of the directories inside it symlinks. + * + * If the plugin contains a symlink, we will first copy it to a temp directory, potentially omitting any + * symlinks that are excluded via the `.distignore` file, avoiding recursive loops as described in #57. + * + * @param string $path The filepath to the directory to check. + * + * @return bool + */ + $is_path_contains_symlink = static function ( $path ) { + + if ( ! is_dir( $path ) ) { + throw new Exception( 'Path `' . $path . '` is not a directory' ); + } + + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ), + RecursiveIteratorIterator::SELF_FIRST + ); + + /** + * @var RecursiveIteratorIterator $iterator + * @var SplFileInfo $item + */ + foreach ( $iterator as $item ) { + if ( is_link( $item->getPathname() ) ) { + return true; + } + } + return false; + }; + + /** + * Check a file from the plugin against the list of rules in the `.distignore` file. + * + * @param string $relative_filepath Path to the file from the plugin root. + * @param string[] $distignore_entries List of ignore rules. + * + * @return bool True when the file matches a rule in the `.distignore` file. + */ + $is_ignored_file = static function ( $relative_filepath, array $distignore_entries ) { + + foreach ( array_filter( $distignore_entries ) as $entry ) { + + // We don't want to quote `*` in regex pattern, later we'll replace it with `.*`. + $pattern = str_replace( '*', '*', $entry ); + + $pattern = '/' . preg_quote( $pattern, '/' ) . '/'; + + $pattern = str_replace( '*', '.*', $pattern ); + + // If the entry is tied to the beginning of the path, add the `^` regex symbol. + if ( 0 === strpos( $entry, '/' ) ) { + $pattern = '/^' . substr( $pattern, 3 ); + } + + // If the entry begins with `.` (hidden files), tie it to the beginning of directories. + if ( 0 === strpos( $entry, '.' ) ) { + $pattern = '/[^\/]' . substr( $pattern, 1 ); + } + + if ( 1 === preg_match( $pattern, $relative_filepath ) ) { + return true; + } + } + + return false; + }; + + if ( $archive_base !== $source_base || $is_path_contains_symlink( $path ) ) { $plugin_dirname = rtrim( $assoc_args['plugin-dirname'], '/' ); $archive_base = $plugin_dirname; $tmp_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $plugin_dirname . $version . '.' . time(); @@ -130,6 +201,9 @@ public function __invoke( $args, $assoc_args ) { RecursiveIteratorIterator::SELF_FIRST ); foreach ( $iterator as $item ) { + if ( $is_ignored_file( $iterator->getSubPathName(), $maybe_ignored_files ) ) { + continue; + } if ( $item->isDir() ) { mkdir( $new_path . DIRECTORY_SEPARATOR . $iterator->getSubPathName() ); } else {