From 8d754db76f81ab0bd09208c99283a1e2f3b96ad1 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Fri, 14 Aug 2026 13:16:02 +0900 Subject: [PATCH 1/4] Accept script directories realpath() cannot resolve realpath() returns false for stream URIs such as phar://, so booting a CompiledInjector from scripts packaged inside a phar threw ScriptDirNotReadable even though the directory is readable. Fall back to the given path and let the is_dir()/is_readable() checks decide. Compiled scripts bake no absolute paths (ScriptDirRelocationTest), so a phar can carry them as-is. The new test builds a phar in a child process (the suite runs with phar.readonly=1) and resolves an instance through phar://. --- docs/performance.md | 2 +- src/CompiledInjector.php | 9 +++++++-- tests/CompiledInjectorTest.php | 31 +++++++++++++++++++++++++++++++ tests/script/build_phar.php | 19 +++++++++++++++++++ 4 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 tests/script/build_phar.php diff --git a/docs/performance.md b/docs/performance.md index bb507ebb..829045d8 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -56,7 +56,7 @@ return require $file; // happy path: no stat(), just cached opcode A missing compiled script is a build invariant violation (corrupt or incomplete build); PHP 8 makes a failed `require` a catchable `Error` that surfaces naturally. `CompiledInjector::getInstance()` keeps its `file_exists()` pre-check (it reports unbound interfaces as `Unbound`); its redundant -`realpath($this->scriptDir)` — already canonicalised in the constructor — was removed. +`realpath($this->scriptDir)` — already validated in the constructor — was removed. ## Benchmarking correctly diff --git a/src/CompiledInjector.php b/src/CompiledInjector.php index eace33ab..595a47bc 100644 --- a/src/CompiledInjector.php +++ b/src/CompiledInjector.php @@ -66,8 +66,13 @@ final class CompiledInjector implements ScriptInjectorInterface public function __construct(#[ScriptDir] string $scriptDir,) { + // realpath() cannot resolve stream URIs such as phar://; keep those as given $realPath = realpath($scriptDir); - if ($realPath === false || ! is_dir($realPath) || ! is_readable($realPath)) { + if ($realPath === false) { + $realPath = $scriptDir; + } + + if (! is_dir($realPath) || ! is_readable($realPath)) { $message = sprintf('Script directory "%s" is not readable. See https://ray-di.github.io/Ray.Compiler/error/ScriptDirNotReadable', $scriptDir); throw new ScriptDirNotReadable($message); @@ -112,7 +117,7 @@ public function getInstance($interface, $name = Name::ANY) /** @psalm-suppress UnsupportedPropertyReferenceUsage */ $singletons = &$this->singletons; - $scriptDir = $this->scriptDir; // already realpath()d in the constructor + $scriptDir = $this->scriptDir; // validated in the constructor // $scriptDir, $singletons, and $dependencyIndex can be used in the included file /** @var mixed $instance */ diff --git a/tests/CompiledInjectorTest.php b/tests/CompiledInjectorTest.php index 896803af..8d2400f0 100644 --- a/tests/CompiledInjectorTest.php +++ b/tests/CompiledInjectorTest.php @@ -10,11 +10,17 @@ use Ray\Compiler\Exception\ScriptDirNotReadable; use Ray\Di\Exception\Unbound; +use function escapeshellarg; +use function exec; +use function implode; use function mkdir; use function serialize; use function spl_object_hash; +use function sprintf; use function unserialize; +use const PHP_BINARY; + /** @psalm-import-type ScriptDir from CompiledInjector */ class CompiledInjectorTest extends TestCase { @@ -111,4 +117,29 @@ public function testThrowsScriptDirNotReadableException(): void $this->expectExceptionMessage($scriptDir); new CompiledInjector($scriptDir); } + + /** + * realpath() returns false for phar:// paths, but compiled scripts are + * relocatable (see ScriptDirRelocationTest) and phars can carry them. + */ + public function testScriptDirInsidePhar(): void + { + $scriptDir = __DIR__ . '/tmp/' . __FUNCTION__; + @mkdir($scriptDir); + (new Compiler())->compile(new FakeModule(), $scriptDir); + $pharFile = $scriptDir . '/app.phar'; + // The test process runs with phar.readonly=1; build in a child that does not + exec(sprintf( + '%s -d phar.readonly=0 %s %s %s 2>&1', + escapeshellarg(PHP_BINARY), + escapeshellarg(__DIR__ . '/script/build_phar.php'), + escapeshellarg($scriptDir), + escapeshellarg($pharFile), + ), $output, $exitCode); + $this->assertSame(0, $exitCode, implode("\n", $output)); + + $injector = new CompiledInjector('phar://' . $pharFile . '/di'); + $instance = $injector->getInstance(FakeCarInterface::class); + $this->assertInstanceOf(FakeCarInterface::class, $instance); + } } diff --git a/tests/script/build_phar.php b/tests/script/build_phar.php new file mode 100644 index 00000000..6c37cef4 --- /dev/null +++ b/tests/script/build_phar.php @@ -0,0 +1,19 @@ +startBuffering(); +foreach (array_filter((array) glob($argv[1] . '/*')) as $file) { + $phar->addFile($file, 'di/' . basename($file)); +} + +$phar->stopBuffering(); From c5d1c03c01c39847d42f19da2ba70b1a6ee26aa5 Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Fri, 14 Aug 2026 14:18:36 +0900 Subject: [PATCH 2/4] Keep the phar build from consuming its own output The output archive lived inside the globbed source directory, so a rerun would package the previous phar as di/app.phar; Phar also appends to an existing archive, keeping stale entries. Write the phar next to the script directory and recreate it from scratch. --- tests/CompiledInjectorTest.php | 2 +- tests/script/build_phar.php | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/CompiledInjectorTest.php b/tests/CompiledInjectorTest.php index 8d2400f0..1d8dd46b 100644 --- a/tests/CompiledInjectorTest.php +++ b/tests/CompiledInjectorTest.php @@ -127,7 +127,7 @@ public function testScriptDirInsidePhar(): void $scriptDir = __DIR__ . '/tmp/' . __FUNCTION__; @mkdir($scriptDir); (new Compiler())->compile(new FakeModule(), $scriptDir); - $pharFile = $scriptDir . '/app.phar'; + $pharFile = $scriptDir . '.phar'; // outside $scriptDir so the build never globs its own output // The test process runs with phar.readonly=1; build in a child that does not exec(sprintf( '%s -d phar.readonly=0 %s %s %s 2>&1', diff --git a/tests/script/build_phar.php b/tests/script/build_phar.php index 6c37cef4..7f5ca5bc 100644 --- a/tests/script/build_phar.php +++ b/tests/script/build_phar.php @@ -10,6 +10,7 @@ declare(strict_types=1); +@unlink($argv[2]); // Phar appends to an existing archive; start clean $phar = new Phar($argv[2]); $phar->startBuffering(); foreach (array_filter((array) glob($argv[1] . '/*')) as $file) { From e3838c04944a44f98fbf47328cd84a63536f965e Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Fri, 14 Aug 2026 16:51:57 +0900 Subject: [PATCH 3/4] Let $realPath hold only what realpath() returned Reusing $realPath to carry the stream-URI fallback made the name lie. Fold the canonical result back into $scriptDir instead: $realPath now exists only when the path is real, and the validated variable keeps the one concept its name says. --- src/CompiledInjector.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CompiledInjector.php b/src/CompiledInjector.php index 595a47bc..728b12e0 100644 --- a/src/CompiledInjector.php +++ b/src/CompiledInjector.php @@ -68,18 +68,18 @@ public function __construct(#[ScriptDir] { // realpath() cannot resolve stream URIs such as phar://; keep those as given $realPath = realpath($scriptDir); - if ($realPath === false) { - $realPath = $scriptDir; + if ($realPath !== false) { + $scriptDir = $realPath; } - if (! is_dir($realPath) || ! is_readable($realPath)) { + if (! is_dir($scriptDir) || ! is_readable($scriptDir)) { $message = sprintf('Script directory "%s" is not readable. See https://ray-di.github.io/Ray.Compiler/error/ScriptDirNotReadable', $scriptDir); throw new ScriptDirNotReadable($message); } - /** @psalm-var ScriptDir $realPath */ - $this->scriptDir = $realPath; + /** @psalm-var ScriptDir $scriptDir */ + $this->scriptDir = $scriptDir; $this->cacheInjector(); $this->registerLoader(); } From d7782a168011129fae3c8fc549aa009073f9247b Mon Sep 17 00:00:00 2001 From: Akihito Koriyama Date: Fri, 14 Aug 2026 16:57:25 +0900 Subject: [PATCH 4/4] Revert "Let $realPath hold only what realpath() returned" This reverts commit e3838c04944a44f98fbf47328cd84a63536f965e. --- src/CompiledInjector.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/CompiledInjector.php b/src/CompiledInjector.php index 728b12e0..595a47bc 100644 --- a/src/CompiledInjector.php +++ b/src/CompiledInjector.php @@ -68,18 +68,18 @@ public function __construct(#[ScriptDir] { // realpath() cannot resolve stream URIs such as phar://; keep those as given $realPath = realpath($scriptDir); - if ($realPath !== false) { - $scriptDir = $realPath; + if ($realPath === false) { + $realPath = $scriptDir; } - if (! is_dir($scriptDir) || ! is_readable($scriptDir)) { + if (! is_dir($realPath) || ! is_readable($realPath)) { $message = sprintf('Script directory "%s" is not readable. See https://ray-di.github.io/Ray.Compiler/error/ScriptDirNotReadable', $scriptDir); throw new ScriptDirNotReadable($message); } - /** @psalm-var ScriptDir $scriptDir */ - $this->scriptDir = $scriptDir; + /** @psalm-var ScriptDir $realPath */ + $this->scriptDir = $realPath; $this->cacheInjector(); $this->registerLoader(); }