1<?php
2
3declare(strict_types=1);
4
5namespace GuzzleHttp\Psr7;
6
7use Psr\Http\Message\StreamInterface;
8
9/**
10 * PHP stream implementation.
11 */
12class Stream implements StreamInterface
13{
14    /**
15     * @see https://www.php.net/manual/en/function.fopen.php
16     * @see https://www.php.net/manual/en/function.gzopen.php
17     */
18    private const READABLE_MODES = '/r|a\+|ab\+|w\+|wb\+|x\+|xb\+|c\+|cb\+/';
19    private const WRITABLE_MODES = '/a|w|r\+|rb\+|rw|x|c/';
20
21    /** @var resource */
22    private $stream;
23    /** @var int|null */
24    private $size;
25    /** @var bool */
26    private $seekable;
27    /** @var bool */
28    private $readable;
29    /** @var bool */
30    private $writable;
31    /** @var string|null */
32    private $uri;
33    /** @var mixed[] */
34    private $customMetadata;
35
36    /**
37     * This constructor accepts an associative array of options.
38     *
39     * - size: (int) If a read stream would otherwise have an indeterminate
40     *   size, but the size is known due to foreknowledge, then you can
41     *   provide that size, in bytes.
42     * - metadata: (array) Any additional metadata to return when the metadata
43     *   of the stream is accessed.
44     *
45     * @param resource                            $stream  Stream resource to wrap.
46     * @param array{size?: int, metadata?: array} $options Associative array of options.
47     *
48     * @throws \InvalidArgumentException if the stream is not a stream resource
49     */
50    public function __construct($stream, array $options = [])
51    {
52        if (!is_resource($stream)) {
53            throw new \InvalidArgumentException('Stream must be a resource');
54        }
55
56        if (isset($options['size'])) {
57            $this->size = $options['size'];
58        }
59
60        $this->customMetadata = $options['metadata'] ?? [];
61        $this->stream = $stream;
62        $meta = stream_get_meta_data($this->stream);
63        $this->seekable = $meta['seekable'];
64        $this->readable = (bool) preg_match(self::READABLE_MODES, $meta['mode']);
65        $this->writable = (bool) preg_match(self::WRITABLE_MODES, $meta['mode']);
66        $this->uri = $this->getMetadata('uri');
67    }
68
69    /**
70     * Closes the stream when the destructed
71     */
72    public function __destruct()
73    {
74        $this->close();
75    }
76
77    public function __toString(): string
78    {
79        try {
80            if ($this->isSeekable()) {
81                $this->seek(0);
82            }
83
84            return $this->getContents();
85        } catch (\Throwable $e) {
86            if (\PHP_VERSION_ID >= 70400) {
87                throw $e;
88            }
89            trigger_error(sprintf('%s::__toString exception: %s', self::class, (string) $e), E_USER_ERROR);
90
91            return '';
92        }
93    }
94
95    public function getContents(): string
96    {
97        if (!isset($this->stream)) {
98            throw new \RuntimeException('Stream is detached');
99        }
100
101        if (!$this->readable) {
102            throw new \RuntimeException('Cannot read from non-readable stream');
103        }
104
105        return Utils::tryGetContents($this->stream);
106    }
107
108    public function close(): void
109    {
110        if (isset($this->stream)) {
111            if (is_resource($this->stream)) {
112                fclose($this->stream);
113            }
114            $this->detach();
115        }
116    }
117
118    public function detach()
119    {
120        if (!isset($this->stream)) {
121            return null;
122        }
123
124        $result = $this->stream;
125        unset($this->stream);
126        $this->size = $this->uri = null;
127        $this->readable = $this->writable = $this->seekable = false;
128
129        return $result;
130    }
131
132    public function getSize(): ?int
133    {
134        if ($this->size !== null) {
135            return $this->size;
136        }
137
138        if (!isset($this->stream)) {
139            return null;
140        }
141
142        // Clear the stat cache if the stream has a URI
143        if ($this->uri) {
144            clearstatcache(true, $this->uri);
145        }
146
147        $stats = fstat($this->stream);
148        if (is_array($stats) && isset($stats['size'])) {
149            $this->size = $stats['size'];
150
151            return $this->size;
152        }
153
154        return null;
155    }
156
157    public function isReadable(): bool
158    {
159        return $this->readable;
160    }
161
162    public function isWritable(): bool
163    {
164        return $this->writable;
165    }
166
167    public function isSeekable(): bool
168    {
169        return $this->seekable;
170    }
171
172    public function eof(): bool
173    {
174        if (!isset($this->stream)) {
175            throw new \RuntimeException('Stream is detached');
176        }
177
178        return feof($this->stream);
179    }
180
181    public function tell(): int
182    {
183        if (!isset($this->stream)) {
184            throw new \RuntimeException('Stream is detached');
185        }
186
187        $result = ftell($this->stream);
188
189        if ($result === false) {
190            throw new \RuntimeException('Unable to determine stream position');
191        }
192
193        return $result;
194    }
195
196    public function rewind(): void
197    {
198        $this->seek(0);
199    }
200
201    public function seek($offset, $whence = SEEK_SET): void
202    {
203        $whence = (int) $whence;
204
205        if (!isset($this->stream)) {
206            throw new \RuntimeException('Stream is detached');
207        }
208        if (!$this->seekable) {
209            throw new \RuntimeException('Stream is not seekable');
210        }
211        if (fseek($this->stream, $offset, $whence) === -1) {
212            throw new \RuntimeException('Unable to seek to stream position '
213                .$offset.' with whence '.var_export($whence, true));
214        }
215    }
216
217    public function read($length): string
218    {
219        if (!isset($this->stream)) {
220            throw new \RuntimeException('Stream is detached');
221        }
222        if (!$this->readable) {
223            throw new \RuntimeException('Cannot read from non-readable stream');
224        }
225        if ($length < 0) {
226            throw new \RuntimeException('Length parameter cannot be negative');
227        }
228
229        if (0 === $length) {
230            return '';
231        }
232
233        try {
234            $string = fread($this->stream, $length);
235        } catch (\Exception $e) {
236            throw new \RuntimeException('Unable to read from stream', 0, $e);
237        }
238
239        if (false === $string) {
240            throw new \RuntimeException('Unable to read from stream');
241        }
242
243        return $string;
244    }
245
246    public function write($string): int
247    {
248        if (!isset($this->stream)) {
249            throw new \RuntimeException('Stream is detached');
250        }
251        if (!$this->writable) {
252            throw new \RuntimeException('Cannot write to a non-writable stream');
253        }
254
255        // We can't know the size after writing anything
256        $this->size = null;
257        $result = fwrite($this->stream, $string);
258
259        if ($result === false) {
260            throw new \RuntimeException('Unable to write to stream');
261        }
262
263        return $result;
264    }
265
266    /**
267     * @return mixed
268     */
269    public function getMetadata($key = null)
270    {
271        if (!isset($this->stream)) {
272            return $key ? null : [];
273        } elseif (!$key) {
274            return $this->customMetadata + stream_get_meta_data($this->stream);
275        } elseif (isset($this->customMetadata[$key])) {
276            return $this->customMetadata[$key];
277        }
278
279        $meta = stream_get_meta_data($this->stream);
280
281        return $meta[$key] ?? null;
282    }
283}
284