Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
84cc92a
Tests + correct command syntax
BrianHenryIE Jun 3, 2022
956385a
Check is there a symlink in the plugin; copy non-ignored files to temp
BrianHenryIE Jun 3, 2022
666273b
Test for symlink bug
BrianHenryIE Jun 16, 2022
5c9e80e
lint
BrianHenryIE Jun 16, 2022
a23834b
Allow exceptions to PHP's `escapeshellcmd()`
BrianHenryIE Jun 18, 2022
5d20cea
Handle `*` and `/` better in `$is_ignore_file`
BrianHenryIE Jun 30, 2022
54e5785
phpcbf
BrianHenryIE Jun 30, 2022
e67f300
Tests + correct command syntax
BrianHenryIE Jun 3, 2022
a1f922b
Allow exceptions to PHP's `escapeshellcmd()`
BrianHenryIE Jun 18, 2022
dd96e86
Update src/Dist_Archive_Command.php
BrianHenryIE Jul 1, 2022
8653509
Update src/Dist_Archive_Command.php
BrianHenryIE Jul 1, 2022
c8fd1f6
Merge branch 'main' into copy-to-tmp
BrianHenryIE Jul 7, 2022
1482a8a
Merge branch 'main' into anchor-distignore-entries-to-root
BrianHenryIE Jul 7, 2022
e002815
Fix failing tests
BrianHenryIE Jul 7, 2022
62869be
Merge branch 'anchor-distignore-entries-to-root' of https://github.co…
BrianHenryIE Jul 7, 2022
c0dc81f
Merge branch 'copy-to-tmp' of https://github.com/BrianHenryIE/dist-ar…
BrianHenryIE Jul 7, 2022
e38e985
Merge branch 'anchor-distignore-entries-to-root' into pr-58-and-59
BrianHenryIE Jul 7, 2022
6037524
Move is_ignored_file to its own method
BrianHenryIE Jul 7, 2022
3f65e62
Do not escape `*` from shell commands; fix excluding directories from…
BrianHenryIE Jul 8, 2022
80332fb
Tests for hidden files.
BrianHenryIE Jul 8, 2022
5eab0e6
Add 12 PHPUnit tests for `is_ignored_file`.
BrianHenryIE Jul 8, 2022
70817ae
Move `is_path_contains_symlink` to protected function.
BrianHenryIE Jul 8, 2022
3749ff5
Add `behat-rerun` to `composer.json`
BrianHenryIE Aug 3, 2022
280a6f9
Split failing Scenario Outline Example into its own Scenario
BrianHenryIE Aug 3, 2022
4f2e88e
Remove Scenario Outline related to previous commit
BrianHenryIE Aug 3, 2022
012694d
Use `php_uname( 's' )` to test for Linux for `tar` commands
BrianHenryIE Aug 27, 2022
fd6db41
Revert split of test.
BrianHenryIE Aug 27, 2022
2c9d11a
Merge remote-tracking branch 'upstream/main' into pr-58-and-59
BrianHenryIE Aug 27, 2022
6c32a1d
Prevent error notice on PHP 8.1 when `$version` is null
danielbachhuber Aug 31, 2022
71a3ae1
Ignore these PHPCS errors
danielbachhuber Aug 31, 2022
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
1 change: 1 addition & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
147 changes: 144 additions & 3 deletions features/dist-archive.feature
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
<?php
echo 'Hello world;';
"""
And a foo/test-dir/test.php file:
"""
<?php
echo 'Hello world;';
"""
And a foo/maybe-ignore-me.txt file:
"""
Ignore
"""
And a foo/test-dir/maybe-ignore-me.txt file:
"""
Do not ignore
"""
And a foo/test-dir/foo/maybe-ignore-me.txt file:
"""
Do not ignore
"""

When I run `wp dist-archive foo --format=<format> --plugin-dirname=<plugin-dirname>`
Then STDOUT should be:
"""
Success: Created <plugin-dirname>.<extension>
"""
And the <plugin-dirname>.<extension> file should exist

When I run `rm -rf foo`
Then the foo directory should not exist

When I run `rm -rf <plugin-dirname>`
Then the <plugin-dirname> directory should not exist

When I try `<extract> <plugin-dirname>.<extension>`
Then the <plugin-dirname> directory should exist
And the <plugin-dirname>/test.php file should exist
And the <plugin-dirname>/test-dir/test.php file should exist
And the <plugin-dirname>/maybe-ignore-me.txt file should not exist
And the <plugin-dirname>/test-dir/maybe-ignore-me.txt file should exist
And the <plugin-dirname>/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=<format> --plugin-dirname=<plugin-dirname>`
Then STDOUT should be:
"""
Success: Created <plugin-dirname>.<extension>
"""
And the <plugin-dirname>.<extension> file should exist

When I run `rm -rf foo`
Then the foo directory should not exist

When I run `rm -rf <plugin-dirname>`
Then the <plugin-dirname> directory should not exist

When I try `<extract> <plugin-dirname>.<extension>`
Then the <plugin-dirname> directory should exist
And the <plugin-dirname>/.hidden file should not exist
And the <plugin-dirname>/not.hidden file should exist
And the <plugin-dirname>/test-dir/hidden file should not exist
And the <plugin-dirname>/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

Expand Down Expand Up @@ -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:
"""
<?php
/**
Expand All @@ -360,9 +501,9 @@ Feature: Generate a distribution archive of a project
*/
"""

When I try `wp dist-archive . test-plugin.zip`
When I try `wp dist-archive test-plugin`
Then STDERR should contain:
"""
No .distignore file found. All files in directory included in archive.
"""
And the test-plugin.zip file should exist
And the test-plugin.1.0.0.zip file should exist
2 changes: 2 additions & 0 deletions phpcs.xml.dist
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@
<property name="prefixes" type="array">
<element value="WP_CLI\DistArchive"/><!-- Namespaces. -->
<element value="wpcli_dist_archive"/><!-- Global variables and such. -->
<element value="WP_CLI_ROOT" />
<element value="WP_CLI_VENDOR_DIR" />
</property>
</properties>
</rule>
Expand Down
14 changes: 14 additions & 0 deletions phpunit.xml.dist
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
bootstrap="tests/bootstrap.php"
beStrictAboutTestsThatDoNotTestAnything="true"
beStrictAboutOutputDuringTests="true"
beStrictAboutChangesToGlobalState="false">
<testsuites>
<testsuite name="all">
<directory prefix="spec-" suffix=".php">tests/</directory>
<directory prefix="test-" suffix=".php">tests/</directory>
<directory suffix="Test.php">tests/</directory>
</testsuite>
</testsuites>
</phpunit>
140 changes: 129 additions & 11 deletions src/Dist_Archive_Command.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}

Expand All @@ -134,15 +152,15 @@ 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 ) ) {
$version .= '-' . $maybe_hash;
}
}

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();
Expand All @@ -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 {
Expand Down Expand Up @@ -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}" );
Expand Down Expand Up @@ -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( '*', '&ast;', $entry );

$pattern = '/' . preg_quote( $pattern, '/' ) . '/';

$pattern = str_replace( '&ast;', '.*', $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;

}

}
Loading