Passed
Push — master ( e7e380...72c582 )
by Tom
04:40
created

Builder::phpExec()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 21
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 14
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 13
nc 2
nop 2
dl 0
loc 21
ccs 14
cts 14
cp 1
crap 4
rs 9.8333
c 0
b 0
f 0
1
<?php
2
3
/*
4
 * pipelines - run bitbucket pipelines wherever they dock
5
 *
6
 * Copyright 2017, 2018 Tom Klingenberg <[email protected]>
7
 *
8
 * Licensed under GNU Affero General Public License v3.0 or later
9
 */
10
11
namespace Ktomk\Pipelines\PharBuild;
12
13
use DateTime;
14
use Ktomk\Pipelines\File\BbplMatch;
15
use Ktomk\Pipelines\Lib;
16
use Phar;
17
18
class Builder
19
{
20
    /**
21
     * public to allow injection in tests
22
     *
23
     * @var null|resource to write errors to (if not set, standard error)
24
     */
25
    public $errHandle;
26
27
    /**
28
     * @var string path of the phar file to build
29
     */
30
    private $fPhar;
31
32
    /**
33
     * @var array to collect files to build the phar from $localName => $descriptor
34
     */
35
    private $files;
36
37
    /**
38
     * @var string
39
     */
40
    private $stub;
41
42
    /**
43
     * @var array
44
     */
45
    private $errors;
46
47
    /**
48
     * @var int directory depth limit for ** glob pattern
49
     */
50
    private $limit;
51
52
    /**
53
     * @var string pre-generated replacement pattern for self::$limit
54
     */
55
    private $double;
56
57
    /**
58
     * @var array keep file path (as key) do be unlinked on __destruct() (housekeeping)
59
     */
60
    private $unlink = array();
61
62 3
    public function __destruct()
63
    {
64 3
        foreach ($this->unlink as $path => $test) {
65 1
            if (file_exists($path) && unlink($path)) {
66 1
                unset($this->unlink[$path]);
67
            }
68
        }
69 3
    }
70
71
    /**
72
     * @param string $fphar phar file name
73
     * @return Builder
74
     */
75 10
    public static function create($fphar)
76
    {
77 10
        umask(022);
78
79 10
        $builder = new self();
80 10
        $builder->_ctor($fphar);
81
82 10
        return $builder;
83
    }
84
85
    /**
86
     * @param string $file
87
     * @throws \RuntimeException
88
     * @return $this
89
     */
90 3
    public function stubfile($file)
91
    {
92 3
        unset($this->stub);
93
94 3
        if (!$buffer = file_get_contents($file)) {
95 1
            $this->err(sprintf('error reading stubfile: %s', $file));
96
        } else {
97 2
            $this->stub = $buffer;
98
        }
99
100 3
        return $this;
101
    }
102
103
    /**
104
     * set traversal limit for double-dot glob '**'
105
     *
106
     * @param int $limit 0 to 16, 0 makes '**' effectively act like '*' for path segments
107
     * @return $this
108
     */
109 10
    public function limit($limit)
110
    {
111 10
        $limit = (int)min(16, max(0, $limit));
112 10
        $this->limit = $limit;
113 10
        $this->double = $limit
114 10
            ? str_repeat('{*/,', $limit) . str_repeat('}', $limit) . '*'
115 2
            : '*';
116
117 10
        return $this;
118
    }
119
120
    /**
121
     * add files to build the phar archive of
122
     *
123
     * @param string|string[] $pattern one or more patterns to add as relative files
124
     * @param callable $callback [optional] to apply on each file found
125
     * @param string $directory [optional] where to add from
126
     * @param string $alias [optional] prefix local names
127
     * @throws \RuntimeException
128
     * @return $this|Builder
129
     */
130 4
    public function add($pattern, $callback = null, $directory = null, $alias = null)
131
    {
132 4
        if (null !== $directory) {
133 2
            $result = realpath($directory);
134 2
            if (false === $result || !is_dir($result)) {
135 1
                $this->err(sprintf('invalid directory: %s', $directory));
136
137 1
                return $this;
138
            }
139 2
            $directory = $result . '/';
140
        }
141
142 4
        if (null !== $alias) {
143 2
            $result = trim($alias, '/');
144 2
            if ('' === $result) {
145 1
                $this->err(sprintf(
146 1
                    '%s: ineffective alias: %s',
147 1
                    is_array($pattern) ? implode(';', $pattern) : $pattern,
148 1
                    $alias
149
                ));
150 1
                $alias = null;
151
            } else {
152 1
                $alias = $result . '/';
153
            }
154
        }
155
156 4
        foreach ((array)$pattern as $one) {
157 4
            $this->_add($one, $callback, (string)$directory, (string)$alias);
158
        }
159
160 4
        return $this;
161
    }
162
163
    /**
164
     * Take a snapshot of the file when added to the build, makes
165
     * it immune to later content changes.
166
     *
167
     * @throws \RuntimeException
168
     * @return \Closure
169
     */
170
    public function snapShot()
171
    {
172 1
        return function ($file) {
173 1
            $source = fopen($file, 'rb');
174 1
            if (false === $source) {
175 1
                $this->err(sprintf('failed to open for reading: %s', $file));
176
177 1
                return null;
178
            }
179
180 1
            $target = tmpfile();
181 1
            if (false === $target) {
182
                // @codeCoverageIgnoreStart
183
                fclose($source);
184
                $this->err(sprintf('failed to open temp file for writing'));
185
186
                return null;
187
                // @codeCoverageIgnoreEnd
188
            }
189
190 1
            $meta = stream_get_meta_data($target);
191 1
            $snapShotFile = $meta['uri'];
192
193 1
            if (false === (bool)stream_copy_to_stream($source, $target)) {
194
                // @codeCoverageIgnoreStart
195
                $this->err(sprintf('stream copy error: %s', $file));
196
                fclose($source);
197
                fclose($target);
198
                unlink($snapShotFile);
199
200
                return null;
201
                // @codeCoverageIgnoreEnd
202
            }
203 1
            fclose($source);
204
205
            # preserve file from deletion until later cleanup
206 1
            $this->unlink[$snapShotFile] = $target;
207
208 1
            return array('fil', $snapShotFile);
209 1
        };
210
    }
211
212
    /**
213
     * Drop first line from file when added to the build, e.g.
214
     * for removing a shebang line.
215
     *
216
     * @throws \RuntimeException
217
     * @return \Closure
218
     */
219
    public function dropFirstLine()
220
    {
221 3
        return function ($file) {
222 3
            $lines = file($file);
223 3
            if (false === $lines) {
224 1
                $this->err(sprintf('error reading file: %s', $file));
225
226 1
                return null;
227
            }
228 2
            array_shift($lines);
229 2
            $buffer = implode('', $lines);
230
231 2
            return array('str', $buffer);
232 3
        };
233
    }
234
235
    /**
236
     * String replace on file contents
237
     *
238
     * @param string $that
239
     * @param string $with
240
     * @return \Closure
241
     */
242
    public function replace($that, $with)
243
    {
244 1
        return function ($file) use ($that, $with) {
245 1
            $buffer = file_get_contents($file);
246 1
            $buffer = strtr($buffer, array($that => $with));
247
248 1
            return array('str', $buffer);
249 1
        };
250
    }
251
252
    /**
253
     * build phar file and optionally invoke it with parameters for
254
     * a quick smoke test
255
     *
256
     * @param string $params [options]
257
     * @throws \RuntimeException
258
     * @throws \UnexpectedValueException
259
     * @throws \BadMethodCallException
260
     * @return $this
261
     */
262 6
    public function build($params = null)
263
    {
264 6
        $file = $this->fPhar;
265 6
        $files = $this->files;
266
267 6
        $temp = $this->_tempname('.phar');
268 6
        if (false === $temp) {
269
            // @codeCoverageIgnoreStart
270
            $this->err('fatal: failed to create tmp phar archive file');
271
272
            return $this;
273
            // @codeCoverageIgnoreEnd
274
        }
275
276 6
        if (file_exists($file) && !unlink($file)) {
277 1
            $this->err(sprintf("could not unlink existing file '%s'", $file));
278
279 1
            return $this;
280
        }
281
282 5
        if (!Phar::canWrite()) {
283 1
            $this->err("phar: writing phar files is disabled by the php.ini setting 'phar.readonly'");
284
        }
285
286 5
        if (empty($files)) {
287 1
            $this->err('no files, add some or do not remove all');
288
        }
289
290 5
        if (!empty($this->errors)) {
291 3
            $this->err('fatal: build has errors, not building');
292
293 3
            return $this;
294
        }
295
296 2
        $phar = new Phar($temp);
297 2
        $phar->startBuffering();
298
299 2
        if (null !== $this->stub) {
300 1
            $phar->setStub($this->stub);
301
        }
302
303 2
        $count = $this->_bfiles($phar, $files);
304 2
        if (count($files) !== $count) {
305 1
            $this->err(sprintf('only %d of %d files could be added', $count, count($files)));
306
        }
307
308 2
        $phar->stopBuffering();
309 2
        unset($phar); # save file
310
311 2
        if (0 === $count) {
312 1
            $this->err('fatal: no files in phar archive, must have at least one');
313
314 1
            return $this;
315
        }
316
317 1
        copy($temp, $file);
318
319
        # chmod +x for lazy ones
320 1
        if (!chmod($file, 0775)) {
321
            // @codeCoverageIgnoreStart
322
            $this->err('error changing mode to 0775 on phar file');
323
            // @codeCoverageIgnoreEnd
324
        }
325
326
        # smoke test TODO operate on secondary temp file, execution options
327 1
        if (null !== $params) {
328 1
            $this->exec(sprintf('./%s %s', $file, $params), $return);
329 1
            printf("%s\n", $return);
330
        }
331
332 1
        return $this;
333
    }
334
335
    /**
336
     * updates each file's unix timestamps in the phar archive,
337
     * useful for reproducible builds
338
     *
339
     * @param DateTime|int|string $timestamp Date string or DateTime or unix timestamp to use
340
     * @throws \RuntimeException
341
     * @return $this
342
     */
343 2
    public function timestamps($timestamp = null)
344
    {
345 2
        $file = $this->fPhar;
346 2
        if (!file_exists($file)) {
347 1
            $this->err(sprintf('no such file: %s', $file));
348
349 1
            return $this;
350
        }
351 1
        require_once __DIR__ . '/Timestamps.php';
352 1
        $ts = new Timestamps($file);
353 1
        $ts->updateTimestamps($timestamp);
354 1
        $ts->save($this->fPhar, Phar::SHA1);
355
356 1
        return $this;
357
    }
358
359
    /**
360
     * output information about built phar file
361
     * @throws \RuntimeException
362
     * @throws \UnexpectedValueException
363
     * @throws \BadMethodCallException
364
     */
365 2
    public function info()
366
    {
367 2
        $filename = $this->fPhar;
368
369 2
        if (!is_file($filename)) {
370 1
            $this->err(sprintf('no such file: %s', $filename));
371
372 1
            return $this;
373
        }
374
375 1
        printf("file.....: %s\n", $filename);
376 1
        printf("size.....: %s bytes\n", number_format(filesize($filename), 0, '.', ' '));
377 1
        printf("SHA-1....: %s\n", strtoupper(sha1_file($filename)));
378 1
        printf("SHA-256..: %s\n", strtoupper(hash_file('sha256', $filename)));
379
380 1
        $pinfo = new \Phar($filename);
381 1
        printf("file.....: %s\n", $pinfo->getVersion());
382 1
        printf("api......: %s\n", $pinfo::apiVersion());
383 1
        printf("extension: %s\n", phpversion('phar'));
384 1
        printf("php......: %s\n", PHP_VERSION);
385 1
        printf("uname....: %s\n", php_uname('a'));
386 1
        printf("count....: %d file(s)\n", $pinfo->count());
387 1
        $sig = $pinfo->getSignature();
388 1
        printf("signature: %s %s\n", $sig['hash_type'], $sig['hash']);
389
390 1
        return $this;
391
    }
392
393
    /**
394
     * remove from collected files based on pattern
395
     *
396
     * @param $pattern
397
     * @throws \RuntimeException
398
     * @return $this
399
     */
400 2
    public function remove($pattern)
401
    {
402 2
        if (empty($this->files)) {
403 1
            $this->err(sprintf("can not remove from no files (pattern: '%s')", $pattern));
404
405 1
            return $this;
406
        }
407
408 2
        require_once __DIR__ . '/../../src/File/BbplMatch.php';
409
410 2
        $result = array();
411 2
        foreach ($this->files as $key => $value) {
412 2
            if (!BbplMatch::match($pattern, $key)) {
413 2
                $result[$key] = $value;
414
            }
415
        }
416
417 2
        if (count($result) === count($this->files)) {
418 1
            $this->err(sprintf("ineffective removal pattern: '%s'", $pattern));
419
        } else {
420 2
            $this->files = $result;
421
        }
422
423 2
        return $this;
424
    }
425
426
    /**
427
     * execute a system command
428
     *
429
     * @param string $command
430
     * @param string $return [by-ref] last line of the output (w/o newline/white space at end)
431
     * @throws \RuntimeException
432
     * @return $this
433
     */
434 2
    public function exec($command, &$return = null)
435
    {
436 2
        $return = exec($command, $output, $status);
437 2
        if (0 !== $status) {
438 1
            $this->err(sprintf('command failed: %s (exit status: %d)', $command, $status));
439
        }
440
441 2
        $return = rtrim($return);
442
443 2
        return $this;
444
    }
445
446
    /**
447
     * Execute a utility written in PHP w/ the current PHP binary automatically
448
     *
449
     * @param string $command
450
     * @param string $return [by-ref]  last line of the output (w/o newline/white space at end)
451
     *@throws \RuntimeException
452
     * @return $this
453
     * @see Builder::exec()
454
     *
455
     */
456 2
    public function phpExec($command, &$return = null)
457
    {
458 2
        list($utility, $parameters) = preg_split('(\s)', $command, 2) + array(1 => null);
459
460 2
        $status = null;
461 2
        $phpUtility = sprintf(
462 2
            '%s -f %s --',
463 2
            escapeshellcmd(Lib::phpBinary()),
464 2
            is_file($utility) ? $utility : exec(sprintf('which %s', escapeshellarg($utility)), $blank, $status)
465
        );
466 2
        if (null !== $status && 0 !== $status) {
467 1
            $this->err(sprintf(
468 1
                '%s: unable to resolve "%s", verify the file exists and it is an actual php utility',
469 1
                'php command error',
470 1
                $utility
471
            ));
472
473 1
            return $this;
474
        }
475
476 1
        return $this->exec($phpUtility . ' ' . $parameters, $return);
477
    }
478
479
    /**
480
     * @return array error messages
481
     */
482 4
    public function errors()
483
    {
484 4
        return $this->errors;
485
    }
486
487 10
    private function _ctor($fphar)
488
    {
489 10
        $this->files = array();
490 10
        $this->errors = array();
491 10
        $this->limit(9);
492 10
        $this->fPhar = $fphar;
493 10
    }
494
495
    /**
496
     * add files to build the phar archive from
497
     *
498
     * @param string $pattern glob pattern of files to add
499
     * @param callable $callback [optional] callback to apply on each file found
500
     * @param string $directory [optional]
501
     * @param string $alias [optional]
502
     * @throws \RuntimeException
503
     */
504 4
    private function _add($pattern, $callback = null, $directory = null, $alias = null)
505
    {
506
        /** @var string $pwd [optional] previous working directory */
507 4
        $pwd = null;
508
509 4
        if (strlen($directory)) {
510
            // TODO handle errors
511 2
            $pwd = getcwd();
512 2
            chdir($directory);
513
        }
514
515 4
        $results = $this->_glob($pattern);
516 4
        foreach ($results as $result) {
517 4
            if (!is_file($result)) {
518 1
                continue;
519
            }
520
521 4
            $file = $directory . $result;
522 4
            $localName = $alias . $result;
523 4
            $descriptor = array('fil', $file);
524
525 4
            if (null !== $callback) {
526 3
                $descriptor = call_user_func($callback, $file);
527 3
                if (!is_array($descriptor) || 2 !== count($descriptor)) {
528 1
                    $this->err(sprintf(
529 1
                        "%s: invalid callback return for pattern '%s': %s",
530 1
                        $result,
531 1
                        $pattern,
532 1
                        rtrim(var_export($descriptor, true))
533
                    ));
534
535 1
                    continue;
536
                }
537
            }
538
539 4
            $this->files[$localName] = $descriptor;
540
        }
541
542 4
        if (strlen($directory)) {
543
            // TODO handle errors
544 2
            chdir($pwd);
545
        }
546 4
    }
547
548
    /**
549
     * @see Builder::_glob()
550
     *
551
     * @param $glob
552
     * @param $flags
553
     * @throws \RuntimeException
554
     * @return array|bool
555
     */
556 4
    private function _glob_brace($glob, $flags)
557
    {
558 4
        $reservoir = array();
559 4
        $globs = Lib::expandBrace($glob);
560 4
        foreach ($globs as $globEx) {
561 4
            $result = \glob($globEx, $flags);
562 4
            if (false === $result) {
563
                // @codeCoverageIgnoreStart
564
                $this->err(vsprintf(
565
                    "glob failure '%s' <- '%s'",
566
                    array($globEx, $glob)
567
                ));
568
569
                return false;
570
                // @codeCoverageIgnoreEnd
571
            }
572
573 4
            $result = preg_replace('(//+)', '/', $result);
574
575 4
            foreach ($result as $file) {
576 4
                $reservoir["k{$file}"] = $file;
577
            }
578
        }
579
580 4
        return array_values($reservoir);
581
    }
582
583
    /**
584
     * globbing with double dot (**) support
585
     * @param $pattern
586
     * @throws \UnexpectedValueException
587
     * @throws \RuntimeException
588
     * @return array
589
     */
590 4
    private function _glob($pattern)
591
    {
592
        /* enable double-dots (with recursion limit, @see Builder::limit */
593 4
        $glob = strtr($pattern, array('\*' => '\*', '**' => '{' . $this->double . '/,}'));
594
595 4
        $result = $this->_glob_brace($glob, GLOB_NOSORT);
596
597 4
        if (false === $result) {
598
            // @codeCoverageIgnoreStart
599
            $this->err(vsprintf(
600
                "glob failure '%s' -> '%s'",
601
                array($pattern, $glob)
602
            ));
603
604
            return array();
605
            // @codeCoverageIgnoreEnd
606
        }
607 4
        if ($result === array()) {
608 1
            $this->err(sprintf(
609 1
                'ineffective pattern: %s',
610 1
                $pattern === $glob
611 1
                    ? $pattern
612 1
                    : sprintf("'%s' -> '%s'", $pattern, $glob)
613
            ));
614
        }
615 4
        if (!is_array($result)) {
0 ignored issues
show
introduced by
The condition is_array($result) is always true.
Loading history...
616
            // @codeCoverageIgnoreStart
617
            throw new \UnexpectedValueException(
618
                sprintf('glob: return value not an array: %s', var_export($result, true))
619
            );
620
            // @codeCoverageIgnoreEnd
621
        }
622
623 4
        return $result;
624
    }
625
626
    /**
627
     * build chunks from files (sorted by local name)
628
     *
629
     * @param array $files
630
     * @throws \UnexpectedValueException
631
     * @throws \RuntimeException
632
     * @return array
633
     */
634 2
    private function _bchunks(array $files)
635
    {
636 2
        ksort($files, SORT_STRING) || $this->err();
637
638 2
        $lastType = null;
639 2
        $chunks = array();
640 2
        $nodes = null;
641 2
        foreach ($files as $localName => $descriptor) {
642 2
            list($type, $context) = $descriptor;
643
644 2
            if ($type !== $lastType) {
645 2
                unset($nodes);
646 2
                $nodes = array();
647 2
                $chunks[] = array('type' => $type, 'nodes' => &$nodes);
648 2
                $lastType = $type;
649
            }
650
651
            switch ($type) {
652 2
                case 'fil': # type is: key'ed file is (existing) file with relative path on system
653 2
                    if (!is_file($context)) {
654 1
                        $this->err(sprintf('%s: not a file: %s', $localName, $context));
655
                    } else {
656 1
                        $nodes[$localName] = $context;
657
                    }
658
659 2
                    break;
660 1
                case 'str': # type is: key'ed file is string contents
661 1
                    $nodes[$localName] = $context;
662
663 1
                    break;
664
                default:
665 2
                    throw new \UnexpectedValueException(sprintf('unknown type: %s', $type));
666
            }
667
        }
668 2
        unset($nodes);
669
670 2
        return $chunks;
671
    }
672
673
    /**
674
     * create temporary file
675
     *
676
     * @param string $suffix [optional]
677
     * @throws \RuntimeException
678
     * @return bool|string
679
     */
680 6
    private function _tempname($suffix = null)
681
    {
682 6
        $temp = tempnam(sys_get_temp_dir(), 'pharbuild.');
683 6
        if (false === $temp) {
684
            // @codeCoverageIgnoreStart
685
            $this->err('failed to acquire temp filename');
686
687
            return false;
688
            // @codeCoverageIgnoreEnd
689
        }
690
691 6
        if (null !== $suffix) {
692 6
            unlink($temp);
693 6
            $temp .= $suffix;
694
        }
695
696 6
        $this->unlink[$temp] = 1;
697
698 6
        return $temp;
699
    }
700
701
    /**
702
     * @param string $message [optional]
703
     * @throws \RuntimeException
704
     */
705 7
    private function err($message = null)
706
    {
707
        // fallback to global static: if STDIN is used for PHP
708
        // process, the default constants aren't ignored.
709 7
        if (null === $this->errHandle) {
710 5
            $this->errHandle = $this->errHandleFromEnvironment();
711
        }
712
713 7
        $this->errors[] = $message;
714 7
        is_resource($this->errHandle) && fprintf($this->errHandle, "%s\n", $message);
715 7
    }
716
717
    /**
718
     * @throws \RuntimeException
719
     * @return resource handle of the system's standard error stream
720
     */
721 5
    private function errHandleFromEnvironment()
722
    {
723 5
        if (defined('STDERR')) {
724
            // @codeCoverageIgnoreStart
725
            // explicit: phpunit can not test this code cleanly as it is always
726
            // not defined in phpt tests due to PHP having STDERR not set when a
727
            // php file read is STDIN (which is the case for phpt tests for PHP
728
            // code) so this is a work around as this code is tested w/ phpt.
729
            $handle = constant('STDERR');
730
            if (false === is_resource($handle)) {
731
                $message = 'fatal i/o error: failed to acquire stream from STDERR';
732
                $this->errors[] = $message;
733
734
                throw new \RuntimeException($message);
735
            }
736
            // @codeCoverageIgnoreEnd
737
        } else {
738 5
            $handle = fopen('php://stderr', 'wb');
739 5
            if (false === $handle) {
740
                // @codeCoverageIgnoreStart
741
                $message = 'fatal i/o error: failed to open php://stderr';
742
                $this->errors[] = $message;
743
744
                throw new \RuntimeException($message);
745
                // @codeCoverageIgnoreEnd
746
            }
747
        }
748
749 5
        return $handle;
750
    }
751
752
    /**
753
     * @param Phar $phar
754
     * @param array $files
755
     * @throws \RuntimeException
756
     * @return int number of files (successfully) added to the phar file
757
     */
758
    private function _bfiles(Phar $phar, array $files)
759
    {
760
        $builders = array(
761 2
            'fil' => function (array $nodes) use ($phar) {
762 2
                $result = $phar->buildFromIterator(
763 2
                    new \ArrayIterator($nodes)
764
                );
765
766 2
                return count($result);
767 2
            },
768 2
            'str' => function (array $nodes) use ($phar) {
769 1
                $count = 0;
770 1
                foreach ($nodes as $localName => $contents) {
771 1
                    $phar->addFromString($localName, $contents);
772 1
                    $count++;
773
                }
774
775 1
                return $count;
776 2
            },
777
        );
778
779 2
        $count = 0;
780 2
        foreach ($this->_bchunks($files) as $chunk) {
781 2
            $count += call_user_func($builders[$chunk['type']], $chunk['nodes']);
782
        }
783
784 2
        return $count;
785
    }
786
}
787