Passed
Push — master ( 5e712b...61b36a )
by Tom
02:52
created

Builder::errHandleFromEnvironment()   B

Complexity

Conditions 4
Paths 4

Size

Total Lines 25
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 5
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 13
nc 4
nop 0
dl 0
loc 25
ccs 5
cts 5
cp 1
crap 4
rs 8.5806
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
     * @var string path of the phar file to build
22
     */
23
    private $fPhar;
24
25
    /**
26
     * @var array to collect files to build the phar from $localName => $descriptor
27
     */
28
    private $files;
29
30
    /**
31
     * @var string
32
     */
33
    private $stub;
34
35
    /**
36
     * @var array
37
     */
38
    private $errors;
39
40
    /**
41
     * @var int directory depth limit for ** glob pattern
42
     */
43
    private $limit;
44
45
    /**
46
     * @var string pre-generated replacement pattern for self::$limit
47
     */
48
    private $double;
49
50
    /**
51
     * @var array
52
     */
53
    private $deps;
54
55
    /**
56
     * @var array keep file path (as key) do be unlinked on __destruct() (housekeeping)
57
     */
58
    private $unlink;
59
60
    /**
61
     * @param string $fphar phar file name
62
     * @return Builder
63
     */
64 9
    public static function create($fphar)
65
    {
66 9
        $builder = new self();
67 9
        $builder->_ctor($fphar);
68
69 9
        return $builder;
70
    }
71
72 9
    private function _ctor($fphar)
73
    {
74 9
        $this->files = array();
75 9
        $this->errors = array();
76 9
        $this->limit(9);
77 9
        $this->fPhar = $fphar;
78 9
    }
79
80
    /**
81
     * @param string $file
82
     * @return $this
83
     */
84 3
    public function stubfile($file)
85
    {
86 3
        unset($this->stub);
87
88 3
        if (!$buffer = file_get_contents($file)) {
89 1
            $this->err(sprintf('error reading stubfile: %s', $file));
90
        } else {
91 2
            $this->stub = $buffer;
92
        }
93
94 3
        return $this;
95
    }
96
97
    /**
98
     * set traversal limit for double-dot glob '**'
99
     *
100
     * @param int $limit 0 to 16, 0 makes '**' effectively act like '*'
101
     * @return $this
102
     */
103 9
    public function limit($limit)
104
    {
105 9
        $limit = (int)min(16, max(0, $limit));
106 9
        $this->limit = $limit;
107 9
        $this->double = $limit
108 9
            ? str_repeat('{*/,', $limit) . str_repeat('}', $limit) . '*'
109 2
            : '*';
110
111 9
        return $this;
112
    }
113
114
    /**
115
     * add files to build the phar archive of
116
     *
117
     * @param string|string[] $pattern one or more patterns to add as relative files
118
     * @param callable $callback [optional] to apply on each file found
119
     * @param string $directory [optional] where to add from
120
     * @param string $alias [optional] prefix local names
121
     * @return $this|Builder
122
     */
123 4
    public function add($pattern, $callback = null, $directory = null, $alias = null)
124
    {
125 4
        if (null !== $directory) {
126 2
            $result = realpath($directory);
127 2
            if ($result === false || !is_dir($result)) {
128 1
                $this->err(sprintf('invalid directory: %s', $directory));
129 1
                return $this;
130
            }
131 2
            $directory = $result . '/';
132
        }
133
134 4
        if (null !== $alias) {
135 2
            $result = trim($alias, '/');
136 2
            if ($result === '') {
137 1
                $this->err(sprintf(
138 1
                    '%s: ineffective alias: %s',
139 1
                    is_array($pattern) ? implode(';', $pattern) : $pattern,
140 1
                    $alias
141
                ));
142 1
                $alias = null;
143
            } else {
144 1
                $alias = $result . '/';
145
            }
146
        }
147
148 4
        foreach ((array)$pattern as $one) {
149 4
            $this->_add($one, $callback, "$directory", "$alias");
150
        }
151
152 4
        return $this;
153
    }
154
155
    /**
156
     * Take a snapshot of the file when added to the build, makes
157
     * it immune to later content changes.
158
     *
159
     * @return \Closure
160
     */
161
    public function snapShot()
162
    {
163 1
        return function ($file) {
164 1
            $source = fopen($file, 'r');
165 1
            if (false === $source) {
166 1
                $this->err(sprintf('failed to open for reading: %s', $file));
167 1
                return null;
168
            }
169
170 1
            $target = tmpfile();
171 1
            if (false === $target) {
172
                // @codeCoverageIgnoreStart
173
                fclose($source);
174
                $this->err(sprintf('failed to open temp file for writing'));
175
                return null;
176
                // @codeCoverageIgnoreEnd
177
            }
178
179 1
            stream_copy_to_stream($source, $target) || $this->err(sprintf('stream copy error: %s', $file));
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->err(sprintf('stre...opy error: %s', $file)) targeting Ktomk\Pipelines\PharBuild\Builder::err() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
180 1
            fclose($source);
181
182 1
            $meta = stream_get_meta_data($target);
183 1
            $snapShotFile = $meta['uri'];
184 1
            $this->unlink[$snapShotFile] = $target; # (preserve file from deletion)
185
186 1
            return array('fil', $snapShotFile);
187 1
        };
188
    }
189
190
    /**
191
     * Drop first line from file when added to the build, e.g.
192
     * for removing a shebang line.
193
     *
194
     * @return \Closure
195
     */
196
    public function dropFirstLine()
197
    {
198 3
        return function ($file) {
199 3
            $lines = file($file);
200 3
            if (false === $lines) {
201 1
                $this->err(sprintf('error reading file: %s', $file));
202 1
                return null;
203
            }
204 2
            array_shift($lines);
205 2
            $buffer = implode("", $lines);
206
207 2
            return array('str', $buffer);
208 3
        };
209
    }
210
211
    /**
212
     * String replace on file contents
213
     *
214
     * @param string $that
215
     * @param string $with
216
     * @return \Closure
217
     */
218
    public function replace($that, $with)
219
    {
220 1
        return function ($file) use ($that, $with) {
221 1
            $buffer = file_get_contents($file);
222 1
            $buffer = strtr($buffer, array($that => $with));
223
224 1
            return array('str', $buffer);
225 1
        };
226
    }
227
228
    /**
229
     * add files to build the phar archive from
230
     *
231
     * @param string $pattern glob pattern of files to add
232
     * @param callable $callback [optional] callback to apply on each file found
233
     * @param string $directory [optional]
234
     * @param string $alias [optional]
235
     */
236 4
    private function _add($pattern, $callback = null, $directory = null, $alias = null)
237
    {
238
        /** @var string $pwd [optional] previous working directory */
239 4
        $pwd = null;
240
241 4
        if (strlen($directory)) {
242
            // TODO handle errors
243 2
            $pwd = getcwd();
244 2
            chdir($directory);
245
        }
246
247 4
        $results = $this->_glob($pattern);
248 4
        foreach ($results as $result) {
249 4
            if (!is_file($result)) {
250 1
                continue;
251
            }
252
253 4
            $file = $directory . $result;
254 4
            $localName = $alias . $result;
255 4
            $descriptor = array('fil', $file);
256
257 4
            if (null !== $callback) {
258 3
                $descriptor = call_user_func($callback, $file);
259 3
                if (!is_array($descriptor) || count($descriptor) !== 2) {
260 1
                    $this->err(sprintf(
261 1
                        "%s: invalid callback return for pattern '%s': %s",
262 1
                        $result,
263 1
                        $pattern,
264 1
                        rtrim(var_export($descriptor, true))
265
                    ));
266 1
                    continue;
267
                }
268
            }
269
270 4
            $this->files[$localName] = $descriptor;
271
        }
272
273 4
        if (strlen($directory)) {
274
            // TODO handle errors
275 2
            chdir($pwd);
276
        }
277 4
    }
278
279
    /**
280
     * @see Builder::_glob()
281
     *
282
     * @param $glob
283
     * @param $flags
284
     * @return array|bool
285
     */
286 4
    private function _glob_brace($glob, $flags)
287
    {
288 4
        $reservoir = array();
289 4
        $globs = Lib::expandBrace($glob);
290 4
        foreach ($globs as $globEx) {
291 4
            $result = \glob($globEx, $flags);
292 4
            if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false can never be true.
Loading history...
293
                // @codeCoverageIgnoreStart
294
                $this->err(vsprintf(
295
                    "glob failure '%s' <- '%s'",
296
                    array($globEx, $glob)
297
                ));
298
                return false;
299
                // @codeCoverageIgnoreEnd
300
            }
301
302 4
            foreach ($result as $file) {
303 4
                $reservoir["k{$file}"] = $file;
304
            }
305
        }
306
307 4
        return array_values($reservoir);
308
    }
309
310
    /**
311
     * globbing with double dot (**) support
312
     * @param $pattern
313
     * @return array
314
     */
315 4
    private function _glob($pattern)
316
    {
317
        /* enable double-dots (with recursion limit, @see Builder::limit */
318 4
        $glob = strtr($pattern, array('\*' => '\*', '**' => $this->double));
319
320 4
        $result = $this->_glob_brace($glob, GLOB_NOSORT);
321
322 4
        if ($result === false) {
0 ignored issues
show
introduced by
The condition $result === false can never be true.
Loading history...
323
            // @codeCoverageIgnoreStart
324
            $this->err(vsprintf(
325
                "glob failure '%s' -> '%s'",
326
                array($pattern, $glob)
327
            ));
328
            return array();
329
            // @codeCoverageIgnoreEnd
330
        }
331 4
        if ($result === array()) $this->err(sprintf(
332 1
            "ineffective pattern: %s",
333 1
            $pattern === $glob
334 1
                ? $pattern
335 1
                : sprintf("'%s' -> '%s'", $pattern, $glob)
336
        ));
337 4
        if (!is_array($result)) {
0 ignored issues
show
introduced by
The condition ! is_array($result) can never be true.
Loading history...
338
            // @codeCoverageIgnoreStart
339
            throw new \UnexpectedValueException(
340
                sprintf('glob: return value not an array: %s', var_export($result, true))
341
            );
342
            // @codeCoverageIgnoreEnd
343
        }
344
345 4
        return $result;
346
    }
347
348
    /**
349
     * add dependency from vendor
350
     * @param $name
351
     * @return $this
352
     * @deprecated is over-aged for current build, not much need of dependency
353
     *             management and it would be better that composer fixes auto-
354
     *             load dumping.
355
     * @codeCoverageIgnore
356
     */
357
    public function dep($name)
358
    {
359
        # TODO allow ; in patterns (better than array perhaps even) and allow !pattern)
360
        $trim = trim($name, '/');
361
        $prefix = "vendor/$trim/";
362
        $pattern = sprintf('%s**', $prefix);
363
        $this->deps[$trim] = 1;
364
        $this->add($pattern);
365
366
        # so far no deep dependencies to include, keeping for future
367
        # $composer = json_decode(file_get_contents("${prefix}composer.json"), true);
368
        # print_r($composer['require']);
369
370
        return $this;
371
    }
372
373
    /**
374
     * build phar file and optionally invoke it with parameters for
375
     * a quick smoke test
376
     *
377
     * @param string $params [options]
378
     * @return $this
379
     */
380 6
    public function build($params = null)
381
    {
382 6
        $file = $this->fPhar;
383 6
        $files = $this->files;
384
385 6
        $temp = $this->_tempname('.phar');
386 6
        if (false === $temp) {
0 ignored issues
show
introduced by
The condition false === $temp can never be true.
Loading history...
387
            // @codeCoverageIgnoreStart
388
            $this->err('fatal: failed to create tmp phar archive file');
389
            return $this;
390
            // @codeCoverageIgnoreEnd
391
        }
392
393 6
        if (file_exists($file) && !unlink($file)) {
394 1
            $this->err(sprintf("could not unlink existing file '%s'", $file));
395 1
            return $this;
396
        }
397
398 5
        if (!Phar::canWrite()) {
399 1
            $this->err("phar: writing phar files is disabled by the php.ini setting 'phar.readonly'");
400
        }
401
402 5
        if (empty($files)) {
403 1
            $this->err('no files, add some or do not remove all');
404
        }
405
406 5
        if (!empty($this->errors)) {
407 3
            $this->err('fatal: build has errors, not building');
408 3
            return $this;
409
        }
410
411 2
        $phar = new Phar($temp);
412 2
        $phar->startBuffering();
413
414 2
        if (null !== $this->stub) {
0 ignored issues
show
introduced by
The condition null !== $this->stub can never be false.
Loading history...
415 1
            $phar->setStub($this->stub);
416
        }
417
418 2
        $count = $this->_bfiles($phar, $files);
419 2
        if (count($files) !== $count) {
420 1
            $this->err(sprintf('only %d of %d files could be added', $count, count($files)));
421
        }
422
423 2
        $phar->stopBuffering();
424 2
        unset($phar); # save file
425
426 2
        if ($count === 0) {
427 1
            $this->err('fatal: no files in phar archive, must have at least one');
428 1
            return $this;
429
        }
430
431 1
        copy($temp, $file);
432
433
        # chmod +x for lazy ones
434 1
        if (!chmod($file, 0775)) {
435
            // @codeCoverageIgnoreStart
436
            $this->err('error changing mode to 0775 on phar file');
437
            // @codeCoverageIgnoreEnd
438
        }
439
440
        # smoke test TODO operate on secondary temp file, execution options
441 1
        if ($params !== null) {
442 1
            $this->exec(sprintf('./%s %s', $file, $params), $return);
443 1
            printf("%s\n", $return);
444
        }
445
446 1
        return $this;
447
    }
448
449
    /**
450
     * updates each file's unix timestamps in the phar archive,
451
     * useful for reproducible builds
452
     *
453
     * @param int|DateTime|string $timestamp Date string or DateTime or unix timestamp to use
454
     * @return $this
455
     */
456 2
    public function timestamps($timestamp = null)
457
    {
458 2
        $file = $this->fPhar;
459 2
        if (!file_exists($file)) {
460 1
            $this->err(sprintf('no such file: %s', $file));
461 1
            return $this;
462
        }
463 1
        require_once __DIR__ . '/Timestamps.php';
464 1
        $ts = new Timestamps($file);
465 1
        $ts->updateTimestamps($timestamp);
466 1
        $ts->save($this->fPhar, Phar::SHA1);
467
468 1
        return $this;
469
    }
470
471
    /**
472
     * output information about built phar file
473
     */
474 2
    public function info()
475
    {
476 2
        $filename = $this->fPhar;
477
478 2
        if (!is_file($filename)) {
479 1
            $this->err(sprintf('no such file: %s', $filename));
480 1
            return $this;
481
        }
482
483 1
        printf("file.....: %s\n", $filename);
484 1
        printf("size.....: %s bytes\n", number_format(filesize($filename), 0, '.', ' '));
485 1
        printf("SHA-1....: %s\n", sha1_file($filename));
486 1
        printf("SHA-256..: %s\n", hash_file('sha256', $filename));
487
488 1
        $pinfo = new \Phar($filename);
489 1
        printf("count....: %d file(s)\n", $pinfo->count());
490 1
        $sig = $pinfo->getSignature();
491 1
        printf("signature: %s %s\n", $sig['hash_type'], $sig['hash']);
492
493 1
        return $this;
494
    }
495
496
    /**
497
     * remove from collected files based on pattern
498
     *
499
     * @param $pattern
500
     * @return $this
501
     */
502 2
    public function remove($pattern)
503
    {
504 2
        if (empty($this->files)) {
505 1
            $this->err(sprintf("can not remove from no files (pattern: '%s')", $pattern));
506 1
            return $this;
507
        }
508
509 2
        require_once __DIR__ . '/../../src/File/BbplMatch.php';
510
511 2
        $result = array();
512 2
        foreach ($this->files as $key => $value) {
513 2
            if (!BbplMatch::match($pattern, $key)) {
514 2
                $result[$key] = $value;
515
            }
516
        }
517
518 2
        if (count($result) === count($this->files)) {
519 1
            $this->err(sprintf("ineffective removal pattern: '%s'", $pattern));
520
        } else {
521 2
            $this->files = $result;
522
        }
523
524 2
        return $this;
525
    }
526
527
    /**
528
     * execute a system command
529
     *
530
     * @param string $command
531
     * @param string $return [by-ref] last line of the output (w/o newline/white space at end)
532
     * @return $this
533
     */
534 2
    public function exec($command, &$return = null)
535
    {
536 2
        $return = exec($command, $output, $status);
537 2
        if ($status !== 0) {
538 1
            $this->err(sprintf('command failed: %s (exit status: %d)', $command, $status));
539
        }
540
541 2
        $return = rtrim($return);
542
543 2
        return $this;
544
    }
545
546
    /**
547
     * build chunks from files (sorted by local name)
548
     *
549
     * @param array $files
550
     * @return array
551
     */
552 2
    private function _bchunks(array $files)
553
    {
554 2
        ksort($files, SORT_STRING) || $this->err();
0 ignored issues
show
Bug introduced by
Are you sure the usage of $this->err() targeting Ktomk\Pipelines\PharBuild\Builder::err() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
555
556 2
        $lastType = null;
557 2
        $chunks = array();
558 2
        $nodes = null;
559 2
        foreach ($files as $localName => $descriptor) {
560 2
            list($type, $context) = $descriptor;
561
562 2
            if ($type !== $lastType) {
563 2
                unset($nodes);
564 2
                $nodes = array();
565 2
                $chunks[] = array('type' => $type, 'nodes' => &$nodes);
566 2
                $lastType = $type;
567
            }
568
569
            switch ($type) {
570 2
                case 'fil': # type is: key'ed file is (existing) file with relative path on system
571 2
                    if (!is_file($context)) {
572 1
                        $this->err(sprintf('%s: not a file: %s', $localName, $context));
573
                    } else {
574 1
                        $nodes[$localName] = $context;
575
                    }
576 2
                    break;
577
578 1
                case 'str': # type is: key'ed file is string contents
579 1
                    $nodes[$localName] = $context;
580 1
                    break;
581
582
                default:
583 2
                    throw new \UnexpectedValueException(sprintf("unknown type: %s", $type));
584
            }
585
        }
586 2
        unset($nodes);
587
588 2
        return $chunks;
589
    }
590
591
    /**
592
     * create temporary file
593
     *
594
     * @param string $suffix [optional]
595
     * @return bool|string
596
     */
597 6
    private function _tempname($suffix = null)
598
    {
599 6
        $temp = tempnam(sys_get_temp_dir(), 'pharbuild.');
600 6
        if (false === $temp) {
0 ignored issues
show
introduced by
The condition false === $temp can never be true.
Loading history...
601
            // @codeCoverageIgnoreStart
602
            $this->err('failed to acquire temp filename');
603
            return false;
604
            // @codeCoverageIgnoreEnd
605
        }
606
607 6
        if (null !== $suffix) {
608 6
            unlink($temp);
609 6
            $temp .= $suffix;
610
        }
611
612 6
        $this->unlink[$temp] = 1;
613
614 6
        return $temp;
615
    }
616
617
618
    /**
619
     * @return array error messages
620
     */
621 3
    public function errors()
622
    {
623 3
        return $this->errors;
624
    }
625
626
    /**
627
     * public to allow injection in tests
628
     *
629
     * @var null|resource to write errors to (if not set, standard error)
630
     */
631
    public $errHandle;
632
633
    /**
634
     * @param string $message [optional]
635
     */
636 6
    private function err($message = null)
637
    {
638
        // fallback to global static: if STDIN is used for PHP
639
        // process, the default constants aren't ignored.
640 6
        if (null === $this->errHandle) {
641 5
            $this->errHandle = $this->errHandleFromEnvironment();
0 ignored issues
show
Documentation Bug introduced by
It seems like $this->errHandleFromEnvironment() can also be of type false. However, the property $errHandle is declared as type resource|null. Maybe add an additional type check?

Our type inference engine has found a suspicous assignment of a value to a property. This check raises an issue when a value that can be of a mixed type is assigned to a property that is type hinted more strictly.

For example, imagine you have a variable $accountId that can either hold an Id object or false (if there is no account id yet). Your code now assigns that value to the id property of an instance of the Account class. This class holds a proper account, so the id value must no longer be false.

Either this assignment is in error or a type check should be added for that assignment.

class Id
{
    public $id;

    public function __construct($id)
    {
        $this->id = $id;
    }

}

class Account
{
    /** @var  Id $id */
    public $id;
}

$account_id = false;

if (starsAreRight()) {
    $account_id = new Id(42);
}

$account = new Account();
if ($account instanceof Id)
{
    $account->id = $account_id;
}
Loading history...
642
        }
643
644 6
        $this->errors[] = $message;
645 6
        is_resource($this->errHandle) && fprintf($this->errHandle, "%s\n", $message);
646 6
    }
647
648 5
    private function errHandleFromEnvironment()
649
    {
650 5
        if (defined('STDERR')) {
651
            // @codeCoverageIgnoreStart
652
            // phpunit can't tests this cleanly as it is always not defined in
653
            // phpt tests
654
            $handle = constant('STDERR');
655
            if (false === is_resource($handle)) {
656
                $message = 'fatal i/o error: failed to acquire stream from STDERR';
657
                $this->errors[] = $message;
658
                throw new \RuntimeException($message);
659
            }
660
            // @codeCoverageIgnoreEnd
661
        } else {
662 5
            $handle = fopen('php://stderr', 'w');
663 5
            if (false === $handle) {
664
                // @codeCoverageIgnoreStart
665
                $message = 'fatal i/o error: failed to open php://stderr';
666
                $this->errors[] = $message;
667
                throw new \RuntimeException($message);
668
                // @codeCoverageIgnoreEnd
669
            }
670
        }
671
672 5
        return $handle;
673
    }
674
675 2
    public function __destruct()
676
    {
677 2
        foreach ((array)$this->unlink as $path => $test) {
678 1
            if (file_exists($path) && unlink($path)) {
679 1
                unset($this->unlink[$path]);
680
            }
681
        }
682 2
    }
683
684
    /**
685
     * @param Phar $phar
686
     * @param array $files
687
     * @return int number of files (successfully) added to the phar file
688
     */
689
    private function _bfiles(Phar $phar, array $files)
690
    {
691
        $builders = array(
692 2
            'fil' => function (array $nodes) use ($phar) {
693 2
                $result = $phar->buildFromIterator(
694 2
                    new \ArrayIterator($nodes)
695
                );
696 2
                return count($result);
697 2
            },
698 2
            'str' => function (array $nodes) use ($phar) {
699 1
                $count = 0;
700 1
                foreach ($nodes as $localName => $contents) {
701 1
                    $phar->addFromString($localName, $contents);
702 1
                    $count++;
703
                }
704 1
                return $count;
705 2
            },
706
        );
707
708 2
        $count = 0;
709 2
        foreach ($this->_bchunks($files) as $chunk) {
710 2
            $count += call_user_func($builders[$chunk['type']], $chunk['nodes']);
711
        }
712
713 2
        return $count;
714
    }
715
}
716