1: <?php declare(strict_types=1);
2:
3: namespace Salient\Testing\Core;
4:
5: use Salient\Core\AbstractStreamWrapper;
6: use Salient\Utility\Inflect;
7: use Salient\Utility\Regex;
8: use Salient\Utility\Str;
9: use LogicException;
10: use RuntimeException;
11:
12: /**
13: * @api
14: */
15: final class MockPhpStream extends AbstractStreamWrapper
16: {
17: /** @var array<string,string> */
18: private static array $Data = [];
19: /** @var array<string,int> */
20: private static array $Length = [];
21: /** @var array<string,int> */
22: private static array $OpenCount = [];
23: private static ?string $Protocol = null;
24: private static bool $RestoreProtocol;
25: private string $Path;
26: private int $Offset;
27: private bool $Eof;
28:
29: /**
30: * Register the wrapper as a protocol handler
31: *
32: * @throws LogicException if the wrapper is already registered.
33: */
34: public static function register(string $protocol = 'php'): void
35: {
36: if (self::$Protocol !== null) {
37: throw new LogicException('Already registered');
38: }
39: $restore = in_array($protocol, stream_get_wrappers(), true);
40: if (!(
41: (!$restore || stream_wrapper_unregister($protocol))
42: && stream_wrapper_register($protocol, static::class)
43: )) {
44: throw new RuntimeException('Stream wrapper registration failed');
45: }
46: self::$Protocol = $protocol;
47: self::$RestoreProtocol = $restore;
48: }
49:
50: /**
51: * Deregister the wrapper and restore the protocol handler it replaced (if
52: * applicable)
53: *
54: * @throws LogicException if the wrapper is not registered.
55: */
56: public static function deregister(): void
57: {
58: if (self::$Protocol === null) {
59: throw new LogicException('Not registered');
60: }
61: if (!(
62: stream_wrapper_unregister(self::$Protocol)
63: && (!self::$RestoreProtocol || stream_wrapper_restore(self::$Protocol))
64: )) {
65: throw new RuntimeException('Stream wrapper deregistration failed');
66: }
67: self::$Protocol = null;
68: }
69:
70: /**
71: * Clear the wrapper's stream cache
72: *
73: * @throws LogicException if the wrapper has any open streams.
74: */
75: public static function reset(): void
76: {
77: if (self::$OpenCount) {
78: throw new LogicException(Inflect::format(
79: array_sum(self::$OpenCount),
80: '{{#}} {{#:stream}} {{#:is}} open',
81: ));
82: }
83: self::$Data = [];
84: self::$Length = [];
85: self::$OpenCount = [];
86: }
87:
88: /**
89: * @inheritDoc
90: */
91: public function stream_cast(int $cast_as)
92: {
93: return false;
94: }
95:
96: /**
97: * @inheritDoc
98: */
99: public function stream_close(): void
100: {
101: if (--self::$OpenCount[$this->Path] === 0) {
102: unset(self::$OpenCount[$this->Path]);
103: }
104: unset($this->Path, $this->Offset, $this->Eof);
105: }
106:
107: /**
108: * @inheritDoc
109: */
110: public function stream_eof(): bool
111: {
112: return $this->Eof;
113: }
114:
115: /**
116: * @inheritDoc
117: */
118: public function stream_flush(): bool
119: {
120: return true;
121: }
122:
123: /**
124: * @inheritDoc
125: */
126: public function stream_open(string $path, string $mode, int $options, ?string &$opened_path): bool
127: {
128: $path = $this->normalisePath($path, $options);
129: if ($path === false) {
130: return false;
131: }
132: self::$Data[$path] ??= '';
133: self::$Length[$path] ??= 0;
134: self::$OpenCount[$path] ??= 0;
135: self::$OpenCount[$path]++;
136: $this->Path = $path;
137: $this->Offset = 0;
138: $this->Eof = false;
139: return true;
140: }
141:
142: /**
143: * @inheritDoc
144: */
145: public function stream_read(int $count)
146: {
147: $count = max(0, min($count, self::$Length[$this->Path] - $this->Offset));
148: $data = substr(self::$Data[$this->Path], $this->Offset, $count);
149: $this->Offset += $count;
150: if ($this->Offset >= self::$Length[$this->Path]) {
151: $this->Eof = true;
152: }
153: return $data;
154: }
155:
156: /**
157: * @inheritDoc
158: */
159: public function stream_seek(int $offset, int $whence = \SEEK_SET): bool
160: {
161: if ($offset < 0) {
162: return false;
163: }
164: switch ($whence) {
165: case \SEEK_SET:
166: $this->Offset = $offset;
167: break;
168: case \SEEK_CUR:
169: $this->Offset += $offset;
170: break;
171: case \SEEK_END:
172: $this->Offset = self::$Length[$this->Path] + $offset;
173: break;
174: }
175: $this->Eof = false;
176: return true;
177: }
178:
179: /**
180: * @inheritDoc
181: */
182: public function stream_tell(): int
183: {
184: return $this->Offset;
185: }
186:
187: /**
188: * @inheritDoc
189: */
190: public function stream_truncate(int $new_size): bool
191: {
192: if ($new_size < 0) {
193: return false;
194: }
195: if ($new_size <= self::$Length[$this->Path]) {
196: self::$Data[$this->Path] = substr(self::$Data[$this->Path], 0, $new_size);
197: } else {
198: self::$Data[$this->Path] .= str_repeat("\0", $new_size - self::$Length[$this->Path]);
199: }
200: self::$Length[$this->Path] = $new_size;
201: return true;
202: }
203:
204: /**
205: * @inheritDoc
206: */
207: public function stream_write(string $data): int
208: {
209: if ($this->Offset > self::$Length[$this->Path]) {
210: self::$Data[$this->Path] .= str_repeat("\0", $this->Offset - self::$Length[$this->Path]);
211: }
212: $length = strlen($data);
213: self::$Data[$this->Path] = substr_replace(self::$Data[$this->Path], $data, $this->Offset, $length);
214: $this->Offset += $length;
215: self::$Length[$this->Path] = max(self::$Length[$this->Path], $this->Offset);
216: return $length;
217: }
218:
219: /**
220: * @return string|false
221: */
222: private function normalisePath(string $path, int $options)
223: {
224: $path = Str::lower($path);
225: [$scheme, $path] = explode('://', $path, 2);
226: $parts = explode('/', $path);
227: switch ($parts[0]) {
228: case 'stdin':
229: case 'stdout':
230: case 'stderr':
231: case 'input':
232: case 'output':
233: case 'memory':
234: if (count($parts) > 1) {
235: return $this->maybeReportError($options);
236: }
237: break;
238:
239: case 'fd':
240: if (count($parts) !== 2 || Regex::match('/[^0-9]/', $parts[1])) {
241: return $this->maybeReportError($options);
242: }
243: $parts[1] = (string) (int) $parts[1];
244: break;
245:
246: case 'temp':
247: $parts = [$parts[0]];
248: break;
249:
250: case 'filter':
251: return $this->maybeReportError($options, $scheme . '://filter not supported');
252:
253: default:
254: return $this->maybeReportError($options);
255: }
256:
257: return $scheme . '://' . implode('/', $parts);
258: }
259:
260: /**
261: * @return false
262: */
263: private function maybeReportError(int $options, string $message = 'Invalid path'): bool
264: {
265: if ($options & \STREAM_REPORT_ERRORS) {
266: trigger_error($message, \E_USER_ERROR);
267: }
268: return false;
269: }
270: }
271: