diff --git a/composer.json b/composer.json index 79b1032..bad5e0e 100644 --- a/composer.json +++ b/composer.json @@ -42,6 +42,7 @@ "prefer-stable": true, "scripts": { "behat": "run-behat-tests", + "behat-rerun": "rerun-behat-tests", "lint": "run-linter-tests", "phpcs": "run-phpcs-tests", "phpunit": "run-php-unit-tests", diff --git a/features/dist-archive.feature b/features/dist-archive.feature index 41ecd7b..47df002 100644 --- a/features/dist-archive.feature +++ b/features/dist-archive.feature @@ -203,6 +203,113 @@ Feature: Generate a distribution archive of a project | zip | zip | unzip | | targz | tar.gz | tar -zxvf | + Scenario Outline: Ignores files specified with absolute path and not similarly named files + Given an empty directory + And a foo/.distignore file: + """ + /maybe-ignore-me.txt + """ + And a foo/test.php file: + """ + --plugin-dirname=` + Then STDOUT should be: + """ + Success: Created . + """ + And the . file should exist + + When I run `rm -rf foo` + Then the foo directory should not exist + + When I run `rm -rf ` + Then the directory should not exist + + When I try ` .` + Then the directory should exist + And the /test.php file should exist + And the /test-dir/test.php file should exist + And the /maybe-ignore-me.txt file should not exist + And the /test-dir/maybe-ignore-me.txt file should exist + And the /test-dir/foo/maybe-ignore-me.txt file should exist + + Examples: + | format | extension | extract | plugin-dirname | + | zip | zip | unzip | foo | + | targz | tar.gz | tar -zxvf | foo | + | zip | zip | unzip | bar | + | targz | tar.gz | tar -zxvf | bar2 | + + Scenario Outline: Correctly ignores hidden files when specified in distignore + Given an empty directory + And a foo/.distignore file: + """ + .* + """ + And a foo/.hidden file: + """ + Ignore + """ + And a foo/test-dir/.hidden file: + """ + Ignore + """ + And a foo/not.hidden file: + """ + Do not ignore + """ + And a foo/test-dir/not.hidden file: + """ + Do not ignore + """ + + When I run `wp dist-archive foo --format= --plugin-dirname=` + Then STDOUT should be: + """ + Success: Created . + """ + And the . file should exist + + When I run `rm -rf foo` + Then the foo directory should not exist + + When I run `rm -rf ` + Then the directory should not exist + + When I try ` .` + Then the directory should exist + And the /.hidden file should not exist + And the /not.hidden file should exist + And the /test-dir/hidden file should not exist + And the /test-dir/not.hidden file should exist + + Examples: + | format | extension | extract | plugin-dirname | + | zip | zip | unzip | foo | + | targz | tar.gz | tar -zxvf | foo | + | zip | zip | unzip | bar3 | + | targz | tar.gz | tar -zxvf | bar4 | + Scenario: Create directories automatically if requested Given a WP install @@ -349,9 +456,43 @@ Feature: Generate a distribution archive of a project 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 + Scenario: Warns but continues when no distignore file is present Given an empty directory - And a test-plugin.php file: + And a test-plugin/test-plugin.php file: """ + + diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..f824ddc --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,14 @@ + + + + tests/ + tests/ + tests/ + + + diff --git a/src/Dist_Archive_Command.php b/src/Dist_Archive_Command.php index a9c23c9..3c496ec 100644 --- a/src/Dist_Archive_Command.php +++ b/src/Dist_Archive_Command.php @@ -101,19 +101,37 @@ public function __invoke( $args, $assoc_args ) { } $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; + + // When zipping directories, we need to exclude both the contents of and the directory itself from the zip file. + foreach ( array_filter( $maybe_ignored_files ) as $file ) { + if ( is_dir( $path . '/' . $file ) ) { + $maybe_ignored_files[] = rtrim( $file, '/' ) . '/*'; + $maybe_ignored_files[] = rtrim( $file, '/' ) . '/'; + } + } + foreach ( $maybe_ignored_files as $file ) { $file = trim( $file ); if ( 0 === strpos( $file, '#' ) || empty( $file ) ) { continue; } - if ( is_dir( $path . '/' . $file ) ) { - $file = rtrim( $file, '/' ) . '/*'; - } + // If a path is tied to the root of the plugin using `/`, match exactly, otherwise match liberally. if ( 'zip' === $assoc_args['format'] ) { - $ignored_files[] = '*/' . $file; + $ignored_files[] = ( 0 === strpos( $file, '/' ) ) + ? $archive_base . $file + : '*/' . $file; } elseif ( 'targz' === $assoc_args['format'] ) { - $ignored_files[] = $file; + if ( php_uname( 's' ) === 'Linux' ) { + $ignored_files[] = ( 0 === strpos( $file, '/' ) ) + ? $archive_base . $file + : '*/' . $file; + } else { + $ignored_files[] = ( 0 === strpos( $file, '/' ) ) + ? '^' . $archive_base . $file + : $file; + } } } @@ -134,7 +152,7 @@ public function __invoke( $args, $assoc_args ) { } } - if ( false !== stripos( $version, '-alpha' ) && is_dir( $path . '/.git' ) ) { + if ( ! empty( $version ) && false !== stripos( $version, '-alpha' ) && is_dir( $path . '/.git' ) ) { $response = WP_CLI::launch( "cd {$path}; git log --pretty=format:'%h' -n 1", false, true ); $maybe_hash = trim( $response->stdout ); if ( $maybe_hash && 7 === strlen( $maybe_hash ) ) { @@ -142,7 +160,7 @@ public function __invoke( $args, $assoc_args ) { } } - if ( isset( $assoc_args['plugin-dirname'] ) && rtrim( $assoc_args['plugin-dirname'], '/' ) !== $archive_base ) { + if ( $archive_base !== $source_base || $this->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(); @@ -153,6 +171,9 @@ public function __invoke( $args, $assoc_args ) { RecursiveIteratorIterator::SELF_FIRST ); foreach ( $iterator as $item ) { + if ( $this->is_ignored_file( $iterator->getSubPathName(), $maybe_ignored_files ) ) { + continue; + } if ( $item->isDir() ) { mkdir( $new_path . DIRECTORY_SEPARATOR . $iterator->getSubPathName() ); } else { @@ -196,16 +217,17 @@ function( $ignored_file ) { if ( '/*' === substr( $ignored_file, -2 ) ) { $ignored_file = substr( $ignored_file, 0, ( strlen( $ignored_file ) - 2 ) ); } - return "--exclude='{$ignored_file}'"; + return "--exclude='{$ignored_file}'"; }, $ignored_files ); $excludes = implode( ' ', $excludes ); - $cmd = "tar {$excludes} -zcvf {$archive_filepath} {$archive_base}"; + $cmd = 'tar ' . ( ( php_uname( 's' ) === 'Linux' ) ? '--anchored ' : '' ) . "{$excludes} -zcvf {$archive_filepath} {$archive_base}"; } WP_CLI::debug( "Running: {$cmd}", 'dist-archive' ); - $ret = WP_CLI::launch( escapeshellcmd( $cmd ), false, true ); + $escaped_shell_command = $this->escapeshellcmd( $cmd, array( '^', '*' ) ); + $ret = WP_CLI::launch( $escaped_shell_command, false, true ); if ( 0 === $ret->return_code ) { $filename = pathinfo( $archive_filepath, PATHINFO_BASENAME ); WP_CLI::success( "Created {$filename}" ); @@ -303,4 +325,100 @@ private function parse_doc_block( $docblock ) { } return $tags; } + + /** + * Run PHP's escapeshellcmd() then undo escaping known intentional characters. + * + * Escaped by default: &#;`|*?~<>^()[]{}$\, \x0A and \xFF. ' and " are escaped when not paired. + * + * @see escapeshellcmd() + * + * @param string $cmd The shell command to escape. + * @param string[] $whitelist Array of exceptions to allow in the escaped command. + * + * @return string + */ + protected function escapeshellcmd( $cmd, $whitelist ) { + + $escaped_command = escapeshellcmd( $cmd ); + + foreach ( $whitelist as $undo_escape ) { + $escaped_command = str_replace( '\\' . $undo_escape, $undo_escape, $escaped_command ); + } + + return $escaped_command; + } + + + /** + * 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 + */ + protected function is_path_contains_symlink( $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. + */ + public function is_ignored_file( $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; + + } + } diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..5b27367 --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,14 @@ +is_ignored_file( $filepath, $distignore_entries ); + + $this->assertEquals( $expected, $result ); + } + + /** + * Example .distignore entries and files. + * + * Array of arrays containing [example filepath, distignore array, expected result]. + * + * @return array,bool>> + */ + public function distignoreSampleData() { + + return array( + // Ignore .hidden files in the root dir when `.*` is specified. + array( + '.hidden', + array( '.*' ), + true, + ), + // Ignore .hidden files in subdirs when `.*` is specified. + array( + 'subdir/.hidden', + array( '.*' ), + true, + ), + // Ignore .hidden files in the root dir when `/.*` is specified. + array( + '.hidden', + array( '/.*' ), + true, + ), + // Do not ignore .hidden files in subdirs dir when `/.*` is specified. + array( + 'subdir/.hidden', + array( '/.*' ), + false, + ), + // Ignore all files in subdir when `subdir/*.*` is specified. + array( + 'subdir/all.files', + array( 'subdir/*.*' ), + true, + ), + // Do not ignore any files in root dir when `subdir/*.*` is specified. + array( + 'all.files', + array( 'subdir/*.*' ), + false, + ), + // Ignore .zip files in the root dir when `*.zip` is specified. + array( + 'earlier-release.zip', + array( '*.zip' ), + true, + ), + // Ignore .zip files in subdirs dir when `*.zip` is specified. + array( + 'subdir/earlier-release.zip', + array( '*.zip' ), + true, + ), + // Ignore .zip files in the root dir when `/*.zip` is specified. + array( + 'earlier-release.zip', + array( '/*.zip' ), + true, + ), + // Do not ignore .zip files in subdirs dir when `/*.zip` is specified. + array( + 'subdir/earlier-release.zip', + array( '/*.zip' ), + true, + ), + // Ignore maybe.txt file in the root dir when `/maybe.txt` is specified. + array( + 'maybe.txt', + array( '/maybe.txt' ), + true, + ), + // Do not ignore maybe.txt file in subdirs when `/maybe.txt` is specified. + array( + 'subdir/maybe.txt', + array( '/maybe.txt' ), + false, + ), + ); + + } + +}