Skip to content

Commit ea5e025

Browse files
committed
Optimize Stream wrapper by intoducing bitmask constant for checking if an operation uses include, require
1 parent 25c4e64 commit ea5e025

2 files changed

Lines changed: 132 additions & 20 deletions

File tree

src/Internal/StreamWrapper.php

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@
1818
*/
1919
final class StreamWrapper implements StreamWrapperInterface
2020
{
21+
/**
22+
* Bitmask constant passed by PHP's Zend Engine when a stream is opened by include/require.
23+
*/
24+
public const STREAM_OPEN_FOR_INCLUDE = 128;
25+
2126
/**
2227
* Context resource provided by PHP stream subsystem.
2328
*
@@ -195,6 +200,12 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
195200
return $this->openDirectHandle($path, $mode);
196201
}
197202

203+
$isInclude = ($options & self::STREAM_OPEN_FOR_INCLUDE) !== 0;
204+
if (! $isInclude) {
205+
return $this->openDirectHandle($path, $mode);
206+
}
207+
208+
198209
if (! str_ends_with(strtolower($path), '.php')) {
199210
return $this->openDirectHandle($path, $mode);
200211
}
@@ -204,20 +215,13 @@ public function stream_open(string $path, string $mode, int $options, ?string &$
204215
}
205216

206217
$normalizedRaw = str_replace('\\', '/', $path);
207-
208218
if (! PathMatcher::mayPathBeIncluded($normalizedRaw)) {
209219
return $this->openDirectHandle($path, $mode);
210220
}
211221

212-
if (isset(self::$appFileDecisionCache[$normalizedRaw])) {
213-
if (! self::$appFileDecisionCache[$normalizedRaw]) {
214-
return $this->openDirectHandle($path, $mode);
215-
}
216-
}
217-
218222
self::unregister();
219-
$exists = (bool) self::silent(fn () => file_exists($path));
220-
$resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false;
223+
$exists = (bool) self::silent(fn() => file_exists($path));
224+
$resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false;
221225
self::register();
222226

223227
if (! $exists || $resolvedPath === false) {
@@ -266,7 +270,7 @@ private function openDirectHandle(string $targetFile, string $mode): bool
266270
{
267271
self::unregister();
268272
/** @var resource|false $handle */
269-
$handle = self::silent(fn () => fopen($targetFile, $mode));
273+
$handle = self::silent(fn() => fopen($targetFile, $mode));
270274
$this->handle = $handle !== false ? $handle : null;
271275
self::register();
272276

@@ -416,7 +420,7 @@ public function url_stat(string $path, int $flags): array|false
416420

417421
self::unregister();
418422
/** @var array<int|string, int>|false $result */
419-
$result = self::silent(fn () => stat($path));
423+
$result = self::silent(fn() => stat($path));
420424
self::register();
421425

422426
if ($result !== false) {
@@ -442,11 +446,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool
442446
$valueArray = \is_array($value) ? $value : [];
443447
$time = $valueArray[0] ?? time();
444448
$atime = $valueArray[1] ?? $time;
445-
$result = (bool) self::silent(fn () => touch($path, (int) $time, (int) $atime));
449+
$result = (bool) self::silent(fn() => touch($path, (int) $time, (int) $atime));
446450
} elseif ($option === STREAM_META_ACCESS) {
447451
/** @var int $mode */
448452
$mode = \is_int($value) ? $value : 0777;
449-
$result = (bool) self::silent(fn () => chmod($path, $mode));
453+
$result = (bool) self::silent(fn() => chmod($path, $mode));
450454
}
451455
self::register();
452456

@@ -457,7 +461,7 @@ public function dir_opendir(string $path, int $options): bool
457461
{
458462
self::unregister();
459463
/** @var resource|false $dh */
460-
$dh = self::silent(fn () => opendir($path));
464+
$dh = self::silent(fn() => opendir($path));
461465
$this->dirHandle = $dh !== false ? $dh : null;
462466
self::register();
463467

@@ -500,7 +504,7 @@ public function mkdir(string $path, int $mode, int $options): bool
500504
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
501505

502506
self::unregister();
503-
$result = (bool) self::silent(fn () => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0));
507+
$result = (bool) self::silent(fn() => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0));
504508
self::register();
505509

506510
return $result;
@@ -512,7 +516,7 @@ public function rmdir(string $path, int $options): bool
512516
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
513517

514518
self::unregister();
515-
$result = (bool) self::silent(fn () => rmdir($path));
519+
$result = (bool) self::silent(fn() => rmdir($path));
516520
self::register();
517521

518522
return $result;
@@ -524,7 +528,7 @@ public function unlink(string $path): bool
524528
unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]);
525529

526530
self::unregister();
527-
$result = (bool) self::silent(fn () => unlink($path));
531+
$result = (bool) self::silent(fn() => unlink($path));
528532
self::register();
529533

530534
return $result;
@@ -542,7 +546,7 @@ public function rename(string $pathFrom, string $pathTo): bool
542546
);
543547

544548
self::unregister();
545-
$result = (bool) self::silent(fn () => rename($pathFrom, $pathTo));
549+
$result = (bool) self::silent(fn() => rename($pathFrom, $pathTo));
546550
self::register();
547551

548552
return $result;
@@ -559,7 +563,7 @@ public function rename(string $pathFrom, string $pathTo): bool
559563
*/
560564
private static function silent(callable $callback): mixed
561565
{
562-
set_error_handler(fn () => true);
566+
set_error_handler(fn() => true);
563567

564568
try {
565569
return $callback();
@@ -632,7 +636,7 @@ private function openCachedStream(string $resolvedPath, string $mode): bool
632636
{
633637
$cacheDir = self::$cacheDir;
634638
if (! is_dir($cacheDir)) {
635-
self::silent(fn () => mkdir($cacheDir, 0777, true));
639+
self::silent(fn() => mkdir($cacheDir, 0777, true));
636640
}
637641

638642
$cachedFile = CacheManager::getCachedFilePath($resolvedPath);

tests/Internal/StreamWrapperTest.php

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,4 +258,112 @@ function testIgnoredFileFunc(int $id): int
258258
}
259259
});
260260
});
261+
262+
describe('File Functions Non-Interference (STREAM_OPEN_FOR_INCLUDE)', function () {
263+
test('file_get_contents() returns raw source code without AST transformation', function () {
264+
StreamWrapper::register();
265+
266+
$tempDir = sys_get_temp_dir() . '/typephp_raw_read_' . uniqid();
267+
mkdir($tempDir, 0777, true);
268+
269+
$testFile = $tempDir . '/SampleService.php';
270+
$rawSource = <<<'PHP'
271+
<?php
272+
273+
declare(strict_types=1);
274+
275+
namespace App\Test;
276+
277+
/**
278+
* @param positive-int $id
279+
* @return non-empty-string
280+
*/
281+
function sampleAction(int $id): string
282+
{
283+
return "id_{$id}";
284+
}
285+
PHP;
286+
file_put_contents($testFile, $rawSource);
287+
288+
try {
289+
Config::set([
290+
'include' => [
291+
str_replace('\\', '/', $tempDir) . '/**',
292+
],
293+
]);
294+
295+
$readSource = file_get_contents($testFile);
296+
297+
expect($readSource)->toBe($rawSource)
298+
->and($readSource)->not()->toContain('RuntimeTypeChecker::setupScope')
299+
->and($readSource)->not()->toContain('RuntimeTypeChecker::checkReturn');
300+
301+
$fp = fopen($testFile, 'r');
302+
expect($fp)->not()->toBeFalse();
303+
$streamContent = fread($fp, 5000);
304+
fclose($fp);
305+
306+
expect($streamContent)->toBe($rawSource)
307+
->and($streamContent)->not()->toContain('RuntimeTypeChecker::setupScope');
308+
309+
require $testFile;
310+
311+
expect(\App\Test\sampleAction(42))->toBe('id_42');
312+
313+
expect(fn() => \App\Test\sampleAction(-5))
314+
->toThrow(TypeError::class, 'positive-int');
315+
} finally {
316+
if (file_exists($testFile)) {
317+
@unlink($testFile);
318+
}
319+
if (is_dir($tempDir)) {
320+
@rmdir($tempDir);
321+
}
322+
}
323+
});
324+
325+
test('file_put_contents() writes data directly without stream interception', function () {
326+
StreamWrapper::register();
327+
328+
$tempDir = sys_get_temp_dir() . '/typephp_raw_write_' . uniqid();
329+
mkdir($tempDir, 0777, true);
330+
331+
$testFile = $tempDir . '/data_write.txt';
332+
$payload = 'raw_unmodified_payload_12345';
333+
334+
try {
335+
$bytesWritten = file_put_contents($testFile, $payload);
336+
337+
expect($bytesWritten)->toBe(\strlen($payload))
338+
->and(file_get_contents($testFile))->toBe($payload);
339+
} finally {
340+
if (file_exists($testFile)) {
341+
@unlink($testFile);
342+
}
343+
if (is_dir($tempDir)) {
344+
@rmdir($tempDir);
345+
}
346+
}
347+
});
348+
349+
test('stream_open options flag differentiates include (128) from normal read (0)', function () {
350+
StreamWrapper::register();
351+
352+
$wrapper = new StreamWrapper();
353+
$openedPath = null;
354+
$testFile = str_replace('\\', '/', realpath(__DIR__ . '/../../tests/Fixtures/Services/HelperService.php') ?: '');
355+
356+
$wrapper->stream_open($testFile, 'r', 0, $openedPath);
357+
$rawContent = $wrapper->stream_read(5000);
358+
$wrapper->stream_close();
359+
360+
expect($rawContent)->not()->toContain('RuntimeTypeChecker::setupScope');
361+
362+
$wrapper->stream_open($testFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath);
363+
$transformedContent = $wrapper->stream_read(5000);
364+
$wrapper->stream_close();
365+
366+
expect($transformedContent)->toContain('RuntimeTypeChecker::setupScope');
367+
});
368+
});
261369
});

0 commit comments

Comments
 (0)