@@ -101,19 +101,37 @@ public function __invoke( $args, $assoc_args ) {
101
101
}
102
102
103
103
$ 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
+
105
115
foreach ( $ maybe_ignored_files as $ file ) {
106
116
$ file = trim ( $ file );
107
117
if ( 0 === strpos ( $ file , '# ' ) || empty ( $ file ) ) {
108
118
continue ;
109
119
}
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.
113
121
if ( 'zip ' === $ assoc_args ['format ' ] ) {
114
- $ ignored_files [] = '*/ ' . $ file ;
122
+ $ ignored_files [] = ( 0 === strpos ( $ file , '/ ' ) )
123
+ ? $ archive_base . $ file
124
+ : '*/ ' . $ file ;
115
125
} 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
+ }
117
135
}
118
136
}
119
137
@@ -134,15 +152,15 @@ public function __invoke( $args, $assoc_args ) {
134
152
}
135
153
}
136
154
137
- if ( false !== stripos ( $ version , '-alpha ' ) && is_dir ( $ path . '/.git ' ) ) {
155
+ if ( ! empty ( $ version ) && false !== stripos ( $ version , '-alpha ' ) && is_dir ( $ path . '/.git ' ) ) {
138
156
$ response = WP_CLI ::launch ( "cd {$ path }; git log --pretty=format:'%h' -n 1 " , false , true );
139
157
$ maybe_hash = trim ( $ response ->stdout );
140
158
if ( $ maybe_hash && 7 === strlen ( $ maybe_hash ) ) {
141
159
$ version .= '- ' . $ maybe_hash ;
142
160
}
143
161
}
144
162
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 ) ) {
146
164
$ plugin_dirname = rtrim ( $ assoc_args ['plugin-dirname ' ], '/ ' );
147
165
$ archive_base = $ plugin_dirname ;
148
166
$ tmp_dir = sys_get_temp_dir () . DIRECTORY_SEPARATOR . $ plugin_dirname . $ version . '. ' . time ();
@@ -153,6 +171,9 @@ public function __invoke( $args, $assoc_args ) {
153
171
RecursiveIteratorIterator::SELF_FIRST
154
172
);
155
173
foreach ( $ iterator as $ item ) {
174
+ if ( $ this ->is_ignored_file ( $ iterator ->getSubPathName (), $ maybe_ignored_files ) ) {
175
+ continue ;
176
+ }
156
177
if ( $ item ->isDir () ) {
157
178
mkdir ( $ new_path . DIRECTORY_SEPARATOR . $ iterator ->getSubPathName () );
158
179
} else {
@@ -196,16 +217,17 @@ function( $ignored_file ) {
196
217
if ( '/* ' === substr ( $ ignored_file , -2 ) ) {
197
218
$ ignored_file = substr ( $ ignored_file , 0 , ( strlen ( $ ignored_file ) - 2 ) );
198
219
}
199
- return "--exclude=' {$ ignored_file }' " ;
220
+ return "--exclude=' {$ ignored_file }' " ;
200
221
},
201
222
$ ignored_files
202
223
);
203
224
$ 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 }" ;
205
226
}
206
227
207
228
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 );
209
231
if ( 0 === $ ret ->return_code ) {
210
232
$ filename = pathinfo ( $ archive_filepath , PATHINFO_BASENAME );
211
233
WP_CLI ::success ( "Created {$ filename }" );
@@ -303,4 +325,100 @@ private function parse_doc_block( $docblock ) {
303
325
}
304
326
return $ tags ;
305
327
}
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 ( '* ' , '* ' , $ entry );
400
+
401
+ $ pattern = '/ ' . preg_quote ( $ pattern , '/ ' ) . '/ ' ;
402
+
403
+ $ pattern = str_replace ( '* ' , '.* ' , $ 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
+
306
424
}
0 commit comments