1<?php declare(strict_types=1);
2
3/*
4 * This file is part of the Monolog package.
5 *
6 * (c) Jordi Boggiano <j.boggiano@seld.be>
7 *
8 * For the full copyright and license information, please view the LICENSE
9 * file that was distributed with this source code.
10 */
11
12namespace Monolog\Handler;
13
14use Monolog\Logger;
15
16/**
17 * Stores to STDIN of any process, specified by a command.
18 *
19 * Usage example:
20 * <pre>
21 * $log = new Logger('myLogger');
22 * $log->pushHandler(new ProcessHandler('/usr/bin/php /var/www/monolog/someScript.php'));
23 * </pre>
24 *
25 * @author Kolja Zuelsdorf <koljaz@web.de>
26 */
27class ProcessHandler extends AbstractProcessingHandler
28{
29    /**
30     * Holds the process to receive data on its STDIN.
31     *
32     * @var resource|bool|null
33     */
34    private $process;
35
36    /**
37     * @var string
38     */
39    private $command;
40
41    /**
42     * @var string|null
43     */
44    private $cwd;
45
46    /**
47     * @var resource[]
48     */
49    private $pipes = [];
50
51    /**
52     * @var array<int, string[]>
53     */
54    protected const DESCRIPTOR_SPEC = [
55        0 => ['pipe', 'r'],  // STDIN is a pipe that the child will read from
56        1 => ['pipe', 'w'],  // STDOUT is a pipe that the child will write to
57        2 => ['pipe', 'w'],  // STDERR is a pipe to catch the any errors
58    ];
59
60    /**
61     * @param  string                    $command Command for the process to start. Absolute paths are recommended,
62     *                                            especially if you do not use the $cwd parameter.
63     * @param  string|null               $cwd     "Current working directory" (CWD) for the process to be executed in.
64     * @throws \InvalidArgumentException
65     */
66    public function __construct(string $command, $level = Logger::DEBUG, bool $bubble = true, ?string $cwd = null)
67    {
68        if ($command === '') {
69            throw new \InvalidArgumentException('The command argument must be a non-empty string.');
70        }
71        if ($cwd === '') {
72            throw new \InvalidArgumentException('The optional CWD argument must be a non-empty string or null.');
73        }
74
75        parent::__construct($level, $bubble);
76
77        $this->command = $command;
78        $this->cwd = $cwd;
79    }
80
81    /**
82     * Writes the record down to the log of the implementing handler
83     *
84     * @throws \UnexpectedValueException
85     */
86    protected function write(array $record): void
87    {
88        $this->ensureProcessIsStarted();
89
90        $this->writeProcessInput($record['formatted']);
91
92        $errors = $this->readProcessErrors();
93        if (empty($errors) === false) {
94            throw new \UnexpectedValueException(sprintf('Errors while writing to process: %s', $errors));
95        }
96    }
97
98    /**
99     * Makes sure that the process is actually started, and if not, starts it,
100     * assigns the stream pipes, and handles startup errors, if any.
101     */
102    private function ensureProcessIsStarted(): void
103    {
104        if (is_resource($this->process) === false) {
105            $this->startProcess();
106
107            $this->handleStartupErrors();
108        }
109    }
110
111    /**
112     * Starts the actual process and sets all streams to non-blocking.
113     */
114    private function startProcess(): void
115    {
116        $this->process = proc_open($this->command, static::DESCRIPTOR_SPEC, $this->pipes, $this->cwd);
117
118        foreach ($this->pipes as $pipe) {
119            stream_set_blocking($pipe, false);
120        }
121    }
122
123    /**
124     * Selects the STDERR stream, handles upcoming startup errors, and throws an exception, if any.
125     *
126     * @throws \UnexpectedValueException
127     */
128    private function handleStartupErrors(): void
129    {
130        $selected = $this->selectErrorStream();
131        if (false === $selected) {
132            throw new \UnexpectedValueException('Something went wrong while selecting a stream.');
133        }
134
135        $errors = $this->readProcessErrors();
136
137        if (is_resource($this->process) === false || empty($errors) === false) {
138            throw new \UnexpectedValueException(
139                sprintf('The process "%s" could not be opened: ' . $errors, $this->command)
140            );
141        }
142    }
143
144    /**
145     * Selects the STDERR stream.
146     *
147     * @return int|bool
148     */
149    protected function selectErrorStream()
150    {
151        $empty = [];
152        $errorPipes = [$this->pipes[2]];
153
154        return stream_select($errorPipes, $empty, $empty, 1);
155    }
156
157    /**
158     * Reads the errors of the process, if there are any.
159     *
160     * @codeCoverageIgnore
161     * @return string Empty string if there are no errors.
162     */
163    protected function readProcessErrors(): string
164    {
165        return (string) stream_get_contents($this->pipes[2]);
166    }
167
168    /**
169     * Writes to the input stream of the opened process.
170     *
171     * @codeCoverageIgnore
172     */
173    protected function writeProcessInput(string $string): void
174    {
175        fwrite($this->pipes[0], $string);
176    }
177
178    /**
179     * {@inheritDoc}
180     */
181    public function close(): void
182    {
183        if (is_resource($this->process)) {
184            foreach ($this->pipes as $pipe) {
185                fclose($pipe);
186            }
187            proc_close($this->process);
188            $this->process = null;
189        }
190    }
191}
192