• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

azjezz / psl / 12713643030

10 Jan 2025 04:47PM UTC coverage: 98.483% (-0.2%) from 98.691%
12713643030

push

github

web-flow
chore: switch to mago (#501)

* chore: switch from php-cs-fixer and phpcs to mago

Signed-off-by: azjezz <azjezz@protonmail.com>

682 of 699 new or added lines in 176 files covered. (97.57%)

3 existing lines in 2 files now uncovered.

5322 of 5404 relevant lines covered (98.48%)

50.89 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

85.37
/src/Psl/Shell/execute.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Psl\Shell;
6

7
use Psl\DateTime\Duration;
8
use Psl\Dict;
9
use Psl\Env;
10
use Psl\Filesystem;
11
use Psl\IO;
12
use Psl\OS;
13
use Psl\Regex;
14
use Psl\SecureRandom;
15
use Psl\Str;
16
use Psl\Vec;
17

18
use function is_resource;
19
use function pack;
20
use function proc_close;
21
use function proc_open;
22
use function strpbrk;
23

24
/**
25
 * Execute an external program.
26
 *
27
 * @param non-empty-string $command The command to execute.
28
 * @param list<string> $arguments The command arguments listed as separate entries.
29
 * @param null|non-empty-string $working_directory The initial working directory for the command.
30
 *                                                 This must be an absolute directory path, or null if you want to
31
 *                                                 use the default value ( the current directory )
32
 * @param array<string, string> $environment A dict with the environment variables for the command that
33
 *                                           will be run.
34
 *
35
 * @psalm-taint-sink shell $command
36
 *
37
 * @throws Exception\FailedExecutionException In case the command resulted in an exit code other than 0.
38
 * @throws Exception\PossibleAttackException In case the command being run is suspicious ( e.g: contains NULL byte ).
39
 * @throws Exception\RuntimeException In case $working_directory doesn't exist, or unable to create a new process.
40
 * @throws Exception\TimeoutException If $timeout is reached before being able to read the process stream.
41
 */
42
function execute(
43
    string $command,
44
    array $arguments = [],
45
    null|string $working_directory = null,
46
    array $environment = [],
47
    ErrorOutputBehavior $error_output_behavior = ErrorOutputBehavior::Discard,
48
    null|Duration $timeout = null,
49
): string {
50
    $arguments = Vec\map($arguments, Internal\escape_argument(...));
22✔
51
    $commandline = Str\join([$command, ...$arguments], ' ');
22✔
52

53
    /** @psalm-suppress MissingThrowsDocblock - safe ( $offset is within-of-bounds ) */
54
    if (Str\contains($commandline, "\0")) {
22✔
55
        throw new Exception\PossibleAttackException('NULL byte detected.');
1✔
56
    }
57

58
    $environment = Dict\merge(Env\get_vars(), $environment);
21✔
59
    $working_directory ??= Env\current_dir();
21✔
60
    if (!Filesystem\is_directory($working_directory)) {
21✔
61
        throw new Exception\RuntimeException('$working_directory does not exist.');
1✔
62
    }
63

64
    $options = [];
20✔
65
    // @codeCoverageIgnoreStart
66
    if (OS\is_windows()) {
67
        $variable_cache = [];
68
        $variable_count = 0;
69
        /** @psalm-suppress MissingThrowsDocblock */
70
        $identifier = 'PHP_STANDARD_LIBRARY_TMP_ENV_' . SecureRandom\string(6);
71
        /** @psalm-suppress MissingThrowsDocblock */
72
        $commandline = Regex\replace_with(
73
            $commandline,
74
            '/"(?:([^"%!^]*+(?:(?:!LF!|"(?:\^[%!^])?+")[^"%!^]*+)++)|[^"]*+ )"/x',
75
            /**
76
             * @param array<array-key, string> $m
77
             *
78
             * @return string
79
             */
80
            static function (array $m) use (&$environment, &$variable_cache, &$variable_count, $identifier): string {
81
                if (!isset($m[1])) {
82
                    return $m[0];
83
                }
84

85
                if (isset($variable_cache[$m[0]])) {
86
                    /** @var string */
87
                    return $variable_cache[$m[0]];
88
                }
89

90
                $value = $m[1];
91
                if (Str\Byte\contains($value, "\0")) {
92
                    $value = Str\Byte\replace($value, "\0", '?');
93
                }
94

95
                if (false === strpbrk($value, "\"%!\n")) {
96
                    return '"' . $value . '"';
97
                }
98

99
                $var = $identifier . ((string) ++$variable_count);
100

101
                $environment[$var] =
102
                    '"' .
103
                    Regex\replace(
104
                        Str\Byte\replace_every($value, [
105
                            '!LF!' => "\n",
106
                            '"^!"' => '!',
107
                            '"^%"' => '%',
108
                            '"^^"' => '^',
109
                            '""' => '"',
110
                        ]),
111
                        '/(\\\\*)"/',
112
                        '$1$1\\"',
113
                    ) .
114
                    '"';
115

116
                /** @var string */
117
                return $variable_cache[$m[0]] = '!' . $var . '!';
118
            },
119
        );
120

121
        $commandline = 'cmd /V:ON /E:ON /D /C (' . Str\Byte\replace($commandline, "\n", ' ') . ')';
122
        $options = [
123
            'bypass_shell' => true,
124
            'blocking_pipes' => false,
125
        ];
126
    } else {
127
        $commandline = Str\format('exec %s', $commandline);
128
    }
129
    // @codeCoverageIgnoreEnd
130
    $descriptor = [
20✔
131
        1 => ['pipe', 'w'],
20✔
132
        2 => ['pipe', 'w'],
20✔
133
    ];
20✔
134
    $process = proc_open($commandline, $descriptor, $pipes, $working_directory, $environment, $options);
20✔
135
    // @codeCoverageIgnoreStart
136
    // not sure how to replicate this, but it can happen \_o.o_/
137
    if (!is_resource($process)) {
138
        throw new Exception\RuntimeException('Failed to open a new process.');
139
    }
140
    // @codeCoverageIgnoreEnd
141

142
    $stdout = new IO\CloseReadStreamHandle($pipes[1]);
20✔
143
    $stderr = new IO\CloseReadStreamHandle($pipes[2]);
20✔
144

145
    try {
146
        $result = '';
20✔
147
        /** @psalm-suppress MissingThrowsDocblock */
148
        foreach (IO\streaming([1 => $stdout, 2 => $stderr], $timeout) as $type => $chunk) {
20✔
149
            if ($chunk) {
20✔
150
                $result .= pack('C1N1', $type, Str\Byte\length($chunk)) . $chunk;
19✔
151
            }
152
        }
153
    } catch (IO\Exception\TimeoutException $previous) {
×
NEW
154
        throw new Exception\TimeoutException(
×
NEW
155
            'reached timeout while the process output is still not readable.',
×
NEW
156
            0,
×
NEW
157
            $previous,
×
NEW
158
        );
×
159
    } finally {
160
        /** @psalm-suppress MissingThrowsDocblock */
161
        $stdout->close();
20✔
162
        /** @psalm-suppress MissingThrowsDocblock */
163
        $stderr->close();
20✔
164

165
        $code = proc_close($process);
20✔
166
    }
167

168
    if ($code !== 0) {
20✔
169
        /** @psalm-suppress MissingThrowsDocblock */
170
        [$stdout_content, $stderr_content] = namespace\unpack($result);
1✔
171

172
        throw new Exception\FailedExecutionException($commandline, $stdout_content, $stderr_content, $code);
1✔
173
    }
174

175
    if (ErrorOutputBehavior::Packed === $error_output_behavior) {
19✔
176
        return $result;
10✔
177
    }
178

179
    /** @psalm-suppress MissingThrowsDocblock */
180
    [$stdout_content, $stderr_content] = namespace\unpack($result);
9✔
181
    return match ($error_output_behavior) {
9✔
182
        ErrorOutputBehavior::Prepend => $stderr_content . $stdout_content,
9✔
183
        ErrorOutputBehavior::Append => $stdout_content . $stderr_content,
9✔
184
        ErrorOutputBehavior::Replace => $stderr_content,
9✔
185
        ErrorOutputBehavior::Discard => $stdout_content,
9✔
186
    };
9✔
187
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc