Skip to content

Commit f5b532c

Browse files
BrianHenryIEschlesseradanielbachhuber
authored
Allow for leading slashes in .distignore, and deal gracefully with symlinks (wp-cli#61)
Co-authored-by: Alain Schlesser <[email protected]> Co-authored-by: Daniel Bachhuber <[email protected]>
1 parent c10f63e commit f5b532c

7 files changed

+413
-14
lines changed

composer.json

+1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
"prefer-stable": true,
4343
"scripts": {
4444
"behat": "run-behat-tests",
45+
"behat-rerun": "rerun-behat-tests",
4546
"lint": "run-linter-tests",
4647
"phpcs": "run-phpcs-tests",
4748
"phpunit": "run-php-unit-tests",

features/dist-archive.feature

+144-3
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,113 @@ Feature: Generate a distribution archive of a project
203203
| zip | zip | unzip |
204204
| targz | tar.gz | tar -zxvf |
205205

206+
Scenario Outline: Ignores files specified with absolute path and not similarly named files
207+
Given an empty directory
208+
And a foo/.distignore file:
209+
"""
210+
/maybe-ignore-me.txt
211+
"""
212+
And a foo/test.php file:
213+
"""
214+
<?php
215+
echo 'Hello world;';
216+
"""
217+
And a foo/test-dir/test.php file:
218+
"""
219+
<?php
220+
echo 'Hello world;';
221+
"""
222+
And a foo/maybe-ignore-me.txt file:
223+
"""
224+
Ignore
225+
"""
226+
And a foo/test-dir/maybe-ignore-me.txt file:
227+
"""
228+
Do not ignore
229+
"""
230+
And a foo/test-dir/foo/maybe-ignore-me.txt file:
231+
"""
232+
Do not ignore
233+
"""
234+
235+
When I run `wp dist-archive foo --format=<format> --plugin-dirname=<plugin-dirname>`
236+
Then STDOUT should be:
237+
"""
238+
Success: Created <plugin-dirname>.<extension>
239+
"""
240+
And the <plugin-dirname>.<extension> file should exist
241+
242+
When I run `rm -rf foo`
243+
Then the foo directory should not exist
244+
245+
When I run `rm -rf <plugin-dirname>`
246+
Then the <plugin-dirname> directory should not exist
247+
248+
When I try `<extract> <plugin-dirname>.<extension>`
249+
Then the <plugin-dirname> directory should exist
250+
And the <plugin-dirname>/test.php file should exist
251+
And the <plugin-dirname>/test-dir/test.php file should exist
252+
And the <plugin-dirname>/maybe-ignore-me.txt file should not exist
253+
And the <plugin-dirname>/test-dir/maybe-ignore-me.txt file should exist
254+
And the <plugin-dirname>/test-dir/foo/maybe-ignore-me.txt file should exist
255+
256+
Examples:
257+
| format | extension | extract | plugin-dirname |
258+
| zip | zip | unzip | foo |
259+
| targz | tar.gz | tar -zxvf | foo |
260+
| zip | zip | unzip | bar |
261+
| targz | tar.gz | tar -zxvf | bar2 |
262+
263+
Scenario Outline: Correctly ignores hidden files when specified in distignore
264+
Given an empty directory
265+
And a foo/.distignore file:
266+
"""
267+
.*
268+
"""
269+
And a foo/.hidden file:
270+
"""
271+
Ignore
272+
"""
273+
And a foo/test-dir/.hidden file:
274+
"""
275+
Ignore
276+
"""
277+
And a foo/not.hidden file:
278+
"""
279+
Do not ignore
280+
"""
281+
And a foo/test-dir/not.hidden file:
282+
"""
283+
Do not ignore
284+
"""
285+
286+
When I run `wp dist-archive foo --format=<format> --plugin-dirname=<plugin-dirname>`
287+
Then STDOUT should be:
288+
"""
289+
Success: Created <plugin-dirname>.<extension>
290+
"""
291+
And the <plugin-dirname>.<extension> file should exist
292+
293+
When I run `rm -rf foo`
294+
Then the foo directory should not exist
295+
296+
When I run `rm -rf <plugin-dirname>`
297+
Then the <plugin-dirname> directory should not exist
298+
299+
When I try `<extract> <plugin-dirname>.<extension>`
300+
Then the <plugin-dirname> directory should exist
301+
And the <plugin-dirname>/.hidden file should not exist
302+
And the <plugin-dirname>/not.hidden file should exist
303+
And the <plugin-dirname>/test-dir/hidden file should not exist
304+
And the <plugin-dirname>/test-dir/not.hidden file should exist
305+
306+
Examples:
307+
| format | extension | extract | plugin-dirname |
308+
| zip | zip | unzip | foo |
309+
| targz | tar.gz | tar -zxvf | foo |
310+
| zip | zip | unzip | bar3 |
311+
| targz | tar.gz | tar -zxvf | bar4 |
312+
206313
Scenario: Create directories automatically if requested
207314
Given a WP install
208315

@@ -349,9 +456,43 @@ Feature: Generate a distribution archive of a project
349456
And the wp-content/plugins/hello-world/.travis.yml file should not exist
350457
And the wp-content/plugins/hello-world/bin directory should not exist
351458

459+
Scenario: Avoids recursive symlink
460+
Given a WP install in wordpress
461+
And a .distignore file:
462+
"""
463+
wp-content
464+
wordpress
465+
"""
466+
467+
When I run `mkdir -p wp-content/plugins`
468+
Then STDERR should be empty
469+
470+
When I run `rm -rf wordpress/wp-content`
471+
Then STDERR should be empty
472+
473+
When I run `ln -s {RUN_DIR}/wp-content {RUN_DIR}/wordpress/wp-content`
474+
Then STDERR should be empty
475+
476+
When I run `wp scaffold plugin hello-world --path=wordpress`
477+
Then the wp-content/plugins/hello-world directory should exist
478+
And the wp-content/plugins/hello-world/hello-world.php file should exist
479+
480+
When I run `mv wp-content/plugins/hello-world/hello-world.php .`
481+
Then STDERR should be empty
482+
483+
When I run `rm -rf wp-content/plugins/hello-world`
484+
Then STDERR should be empty
485+
486+
When I run `ln -s {RUN_DIR} {RUN_DIR}/wp-content/plugins/hello-world`
487+
Then STDERR should be empty
488+
And the wp-content/plugins/hello-world/hello-world.php file should exist
489+
490+
When I run `wp dist-archive . --plugin-dirname=$(basename "{RUN_DIR}")`
491+
Then STDERR should be empty
492+
352493
Scenario: Warns but continues when no distignore file is present
353494
Given an empty directory
354-
And a test-plugin.php file:
495+
And a test-plugin/test-plugin.php file:
355496
"""
356497
<?php
357498
/**
@@ -360,9 +501,9 @@ Feature: Generate a distribution archive of a project
360501
*/
361502
"""
362503

363-
When I try `wp dist-archive . test-plugin.zip`
504+
When I try `wp dist-archive test-plugin`
364505
Then STDERR should contain:
365506
"""
366507
No .distignore file found. All files in directory included in archive.
367508
"""
368-
And the test-plugin.zip file should exist
509+
And the test-plugin.1.0.0.zip file should exist

phpcs.xml.dist

+2
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
<property name="prefixes" type="array">
4949
<element value="WP_CLI\DistArchive"/><!-- Namespaces. -->
5050
<element value="wpcli_dist_archive"/><!-- Global variables and such. -->
51+
<element value="WP_CLI_ROOT" />
52+
<element value="WP_CLI_VENDOR_DIR" />
5153
</property>
5254
</properties>
5355
</rule>

phpunit.xml.dist

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
2+
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.8/phpunit.xsd"
3+
bootstrap="tests/bootstrap.php"
4+
beStrictAboutTestsThatDoNotTestAnything="true"
5+
beStrictAboutOutputDuringTests="true"
6+
beStrictAboutChangesToGlobalState="false">
7+
<testsuites>
8+
<testsuite name="all">
9+
<directory prefix="spec-" suffix=".php">tests/</directory>
10+
<directory prefix="test-" suffix=".php">tests/</directory>
11+
<directory suffix="Test.php">tests/</directory>
12+
</testsuite>
13+
</testsuites>
14+
</phpunit>

src/Dist_Archive_Command.php

+129-11
Original file line numberDiff line numberDiff line change
@@ -101,19 +101,37 @@ public function __invoke( $args, $assoc_args ) {
101101
}
102102

103103
$ignored_files = array();
104-
$archive_base = basename( $path );
104+
$source_base = basename( $path );
105+
$archive_base = isset( $assoc_args['plugin-dirname'] ) ? rtrim( $assoc_args['plugin-dirname'], '/' ) : $source_base;
106+
107+
// When zipping directories, we need to exclude both the contents of and the directory itself from the zip file.
108+
foreach ( array_filter( $maybe_ignored_files ) as $file ) {
109+
if ( is_dir( $path . '/' . $file ) ) {
110+
$maybe_ignored_files[] = rtrim( $file, '/' ) . '/*';
111+
$maybe_ignored_files[] = rtrim( $file, '/' ) . '/';
112+
}
113+
}
114+
105115
foreach ( $maybe_ignored_files as $file ) {
106116
$file = trim( $file );
107117
if ( 0 === strpos( $file, '#' ) || empty( $file ) ) {
108118
continue;
109119
}
110-
if ( is_dir( $path . '/' . $file ) ) {
111-
$file = rtrim( $file, '/' ) . '/*';
112-
}
120+
// If a path is tied to the root of the plugin using `/`, match exactly, otherwise match liberally.
113121
if ( 'zip' === $assoc_args['format'] ) {
114-
$ignored_files[] = '*/' . $file;
122+
$ignored_files[] = ( 0 === strpos( $file, '/' ) )
123+
? $archive_base . $file
124+
: '*/' . $file;
115125
} elseif ( 'targz' === $assoc_args['format'] ) {
116-
$ignored_files[] = $file;
126+
if ( php_uname( 's' ) === 'Linux' ) {
127+
$ignored_files[] = ( 0 === strpos( $file, '/' ) )
128+
? $archive_base . $file
129+
: '*/' . $file;
130+
} else {
131+
$ignored_files[] = ( 0 === strpos( $file, '/' ) )
132+
? '^' . $archive_base . $file
133+
: $file;
134+
}
117135
}
118136
}
119137

@@ -134,15 +152,15 @@ public function __invoke( $args, $assoc_args ) {
134152
}
135153
}
136154

137-
if ( false !== stripos( $version, '-alpha' ) && is_dir( $path . '/.git' ) ) {
155+
if ( ! empty( $version ) && false !== stripos( $version, '-alpha' ) && is_dir( $path . '/.git' ) ) {
138156
$response = WP_CLI::launch( "cd {$path}; git log --pretty=format:'%h' -n 1", false, true );
139157
$maybe_hash = trim( $response->stdout );
140158
if ( $maybe_hash && 7 === strlen( $maybe_hash ) ) {
141159
$version .= '-' . $maybe_hash;
142160
}
143161
}
144162

145-
if ( isset( $assoc_args['plugin-dirname'] ) && rtrim( $assoc_args['plugin-dirname'], '/' ) !== $archive_base ) {
163+
if ( $archive_base !== $source_base || $this->is_path_contains_symlink( $path ) ) {
146164
$plugin_dirname = rtrim( $assoc_args['plugin-dirname'], '/' );
147165
$archive_base = $plugin_dirname;
148166
$tmp_dir = sys_get_temp_dir() . DIRECTORY_SEPARATOR . $plugin_dirname . $version . '.' . time();
@@ -153,6 +171,9 @@ public function __invoke( $args, $assoc_args ) {
153171
RecursiveIteratorIterator::SELF_FIRST
154172
);
155173
foreach ( $iterator as $item ) {
174+
if ( $this->is_ignored_file( $iterator->getSubPathName(), $maybe_ignored_files ) ) {
175+
continue;
176+
}
156177
if ( $item->isDir() ) {
157178
mkdir( $new_path . DIRECTORY_SEPARATOR . $iterator->getSubPathName() );
158179
} else {
@@ -196,16 +217,17 @@ function( $ignored_file ) {
196217
if ( '/*' === substr( $ignored_file, -2 ) ) {
197218
$ignored_file = substr( $ignored_file, 0, ( strlen( $ignored_file ) - 2 ) );
198219
}
199-
return "--exclude='{$ignored_file}'";
220+
return "--exclude='{$ignored_file}'";
200221
},
201222
$ignored_files
202223
);
203224
$excludes = implode( ' ', $excludes );
204-
$cmd = "tar {$excludes} -zcvf {$archive_filepath} {$archive_base}";
225+
$cmd = 'tar ' . ( ( php_uname( 's' ) === 'Linux' ) ? '--anchored ' : '' ) . "{$excludes} -zcvf {$archive_filepath} {$archive_base}";
205226
}
206227

207228
WP_CLI::debug( "Running: {$cmd}", 'dist-archive' );
208-
$ret = WP_CLI::launch( escapeshellcmd( $cmd ), false, true );
229+
$escaped_shell_command = $this->escapeshellcmd( $cmd, array( '^', '*' ) );
230+
$ret = WP_CLI::launch( $escaped_shell_command, false, true );
209231
if ( 0 === $ret->return_code ) {
210232
$filename = pathinfo( $archive_filepath, PATHINFO_BASENAME );
211233
WP_CLI::success( "Created {$filename}" );
@@ -303,4 +325,100 @@ private function parse_doc_block( $docblock ) {
303325
}
304326
return $tags;
305327
}
328+
329+
/**
330+
* Run PHP's escapeshellcmd() then undo escaping known intentional characters.
331+
*
332+
* Escaped by default: &#;`|*?~<>^()[]{}$\, \x0A and \xFF. ' and " are escaped when not paired.
333+
*
334+
* @see escapeshellcmd()
335+
*
336+
* @param string $cmd The shell command to escape.
337+
* @param string[] $whitelist Array of exceptions to allow in the escaped command.
338+
*
339+
* @return string
340+
*/
341+
protected function escapeshellcmd( $cmd, $whitelist ) {
342+
343+
$escaped_command = escapeshellcmd( $cmd );
344+
345+
foreach ( $whitelist as $undo_escape ) {
346+
$escaped_command = str_replace( '\\' . $undo_escape, $undo_escape, $escaped_command );
347+
}
348+
349+
return $escaped_command;
350+
}
351+
352+
353+
/**
354+
* Given the path to a directory, check are any of the directories inside it symlinks.
355+
*
356+
* If the plugin contains a symlink, we will first copy it to a temp directory, potentially omitting any
357+
* symlinks that are excluded via the `.distignore` file, avoiding recursive loops as described in #57.
358+
*
359+
* @param string $path The filepath to the directory to check.
360+
*
361+
* @return bool
362+
*/
363+
protected function is_path_contains_symlink( $path ) {
364+
365+
if ( ! is_dir( $path ) ) {
366+
throw new Exception( 'Path `' . $path . '` is not a directory' );
367+
}
368+
369+
$iterator = new RecursiveIteratorIterator(
370+
new RecursiveDirectoryIterator( $path, RecursiveDirectoryIterator::SKIP_DOTS ),
371+
RecursiveIteratorIterator::SELF_FIRST
372+
);
373+
374+
/**
375+
* @var RecursiveIteratorIterator $iterator
376+
* @var SplFileInfo $item
377+
*/
378+
foreach ( $iterator as $item ) {
379+
if ( is_link( $item->getPathname() ) ) {
380+
return true;
381+
}
382+
}
383+
return false;
384+
}
385+
386+
/**
387+
* Check a file from the plugin against the list of rules in the `.distignore` file.
388+
*
389+
* @param string $relative_filepath Path to the file from the plugin root.
390+
* @param string[] $distignore_entries List of ignore rules.
391+
*
392+
* @return bool True when the file matches a rule in the `.distignore` file.
393+
*/
394+
public function is_ignored_file( $relative_filepath, array $distignore_entries ) {
395+
396+
foreach ( array_filter( $distignore_entries ) as $entry ) {
397+
398+
// We don't want to quote `*` in regex pattern, later we'll replace it with `.*`.
399+
$pattern = str_replace( '*', '&ast;', $entry );
400+
401+
$pattern = '/' . preg_quote( $pattern, '/' ) . '/';
402+
403+
$pattern = str_replace( '&ast;', '.*', $pattern );
404+
405+
// If the entry is tied to the beginning of the path, add the `^` regex symbol.
406+
if ( 0 === strpos( $entry, '/' ) ) {
407+
$pattern = '/^' . substr( $pattern, 3 );
408+
}
409+
410+
// If the entry begins with `.` (hidden files), tie it to the beginning of directories.
411+
if ( 0 === strpos( $entry, '.' ) ) {
412+
$pattern = '/(^|\/)' . substr( $pattern, 1 );
413+
}
414+
415+
if ( 1 === preg_match( $pattern, $relative_filepath ) ) {
416+
return true;
417+
}
418+
}
419+
420+
return false;
421+
422+
}
423+
306424
}

0 commit comments

Comments
 (0)