From 4e69a02af2ef773cda8cc7f243fc3e428dca1b0a Mon Sep 17 00:00:00 2001 From: Naoki Tsuchiya Date: Sat, 11 Jul 2026 21:39:13 +0900 Subject: [PATCH] fix: resolve #[ScriptDir] to __DIR__ instead of baking the compile-time path Ray\Di\Annotation\ScriptDir and Ray\Di\InjectorInterface were bound via CompilerModule::toInstance() with the compile-time absolute scriptDir, so both got var_export()'d as a string literal into every generated script that pulled either binding into the object graph (e.g. via assisted injection or multibinding, which require InjectorInterface internally). Moving the compiled scripts to a different absolute path after compilation (immutable images, CI build vs. runtime path, etc.) either threw ScriptDirNotReadable or silently resolved against a stale path. Since generated scripts always live inside scriptDir, __DIR__ at require-time is always correct. Special-case the ScriptDir binding's container key in InstanceScript::addArg() and Compiler::compile()'s map() callback to emit __DIR__ instead of the baked path. Co-Authored-By: Claude Sonnet 5 --- src/Compiler.php | 6 + src/InstanceScript.php | 9 ++ tests/Fake/FakeScriptDirAopConsumer.php | 19 +++ .../Fake/FakeScriptDirConstructorConsumer.php | 19 +++ tests/Fake/FakeScriptDirConsumerInterface.php | 10 ++ tests/Fake/FakeScriptDirModule.php | 23 +++ tests/Fake/FakeScriptDirProvider.php | 21 +++ tests/Fake/FakeScriptDirSetterConsumer.php | 24 +++ tests/ScriptDirRelocationTest.php | 152 ++++++++++++++++++ 9 files changed, 283 insertions(+) create mode 100644 tests/Fake/FakeScriptDirAopConsumer.php create mode 100644 tests/Fake/FakeScriptDirConstructorConsumer.php create mode 100644 tests/Fake/FakeScriptDirConsumerInterface.php create mode 100644 tests/Fake/FakeScriptDirModule.php create mode 100644 tests/Fake/FakeScriptDirProvider.php create mode 100644 tests/Fake/FakeScriptDirSetterConsumer.php create mode 100644 tests/ScriptDirRelocationTest.php diff --git a/src/Compiler.php b/src/Compiler.php index af27a3e2..aed40461 100644 --- a/src/Compiler.php +++ b/src/Compiler.php @@ -62,6 +62,12 @@ public function compile(AbstractModule $module, string $scriptDir): Scripts $compileVisitor = new CompileVisitor($container); $container->map(static function (DependencyInterface $dependency, string $key) use ($scripts, $compileVisitor): DependencyInterface { assert($dependency instanceof AcceptInterface); + if ($key === InstanceScript::RAY_DI_SCRIPT_DIR) { + $scripts->add($key, 'return __DIR__;'); + + return $dependency; + } + $script = $dependency->accept($compileVisitor); assert(is_string($script)); $scripts->add($key, $script); diff --git a/src/InstanceScript.php b/src/InstanceScript.php index 62469814..fd2f068b 100644 --- a/src/InstanceScript.php +++ b/src/InstanceScript.php @@ -32,6 +32,7 @@ final class InstanceScript { public const RAY_DI_INJECTOR_INTERFACE = 'Ray\Di\InjectorInterface-'; public const RAY_DI_INJECTION_POINT_INTERFACE = 'Ray\Di\InjectionPointInterface-'; + public const RAY_DI_SCRIPT_DIR = '-Ray\Di\Annotation\ScriptDir'; public const COMMENT = '// prototype'; /** @var array */ @@ -57,6 +58,14 @@ public function __construct(Container $container) /** @param mixed $defaultValue */ public function addArg(string $index, bool $isDefaultAvailable, $defaultValue, ReflectionParameter $parameter): void { + // The script dir is where the generated scripts live: resolve it at + // runtime, never bake the compile-time path. + if ($index === self::RAY_DI_SCRIPT_DIR) { + $this->args[] = '__DIR__'; + + return; + } + if (! isset($this->container[$index])) { if ($isDefaultAvailable) { $this->addInstanceArg($defaultValue); diff --git a/tests/Fake/FakeScriptDirAopConsumer.php b/tests/Fake/FakeScriptDirAopConsumer.php new file mode 100644 index 00000000..e1e6b3fe --- /dev/null +++ b/tests/Fake/FakeScriptDirAopConsumer.php @@ -0,0 +1,19 @@ +scriptDir; + } +} diff --git a/tests/Fake/FakeScriptDirConstructorConsumer.php b/tests/Fake/FakeScriptDirConstructorConsumer.php new file mode 100644 index 00000000..ad221db5 --- /dev/null +++ b/tests/Fake/FakeScriptDirConstructorConsumer.php @@ -0,0 +1,19 @@ +scriptDir; + } +} diff --git a/tests/Fake/FakeScriptDirConsumerInterface.php b/tests/Fake/FakeScriptDirConsumerInterface.php new file mode 100644 index 00000000..bf84eb00 --- /dev/null +++ b/tests/Fake/FakeScriptDirConsumerInterface.php @@ -0,0 +1,10 @@ +bind(FakeScriptDirConsumerInterface::class)->annotatedWith('ctor')->to(FakeScriptDirConstructorConsumer::class); + $this->bind(FakeScriptDirConsumerInterface::class)->annotatedWith('setter')->to(FakeScriptDirSetterConsumer::class); + $this->bind(FakeScriptDirConsumerInterface::class)->annotatedWith('provider')->toProvider(FakeScriptDirProvider::class); + $this->bind(FakeScriptDirConsumerInterface::class)->annotatedWith('aop')->to(FakeScriptDirAopConsumer::class); + $this->bindInterceptor( + $this->matcher->subclassesOf(FakeScriptDirAopConsumer::class), + $this->matcher->any(), + [FakeInterceptor::class], + ); + } +} diff --git a/tests/Fake/FakeScriptDirProvider.php b/tests/Fake/FakeScriptDirProvider.php new file mode 100644 index 00000000..6afcf02d --- /dev/null +++ b/tests/Fake/FakeScriptDirProvider.php @@ -0,0 +1,21 @@ + */ +class FakeScriptDirProvider implements ProviderInterface +{ + public function __construct(#[ScriptDir] private string $scriptDir) + { + } + + public function get(): FakeScriptDirConsumerInterface + { + return new FakeScriptDirConstructorConsumer($this->scriptDir); + } +} diff --git a/tests/Fake/FakeScriptDirSetterConsumer.php b/tests/Fake/FakeScriptDirSetterConsumer.php new file mode 100644 index 00000000..dcb0081d --- /dev/null +++ b/tests/Fake/FakeScriptDirSetterConsumer.php @@ -0,0 +1,24 @@ +scriptDir = $scriptDir; + } + + public function getScriptDir(): string + { + return $this->scriptDir; + } +} diff --git a/tests/ScriptDirRelocationTest.php b/tests/ScriptDirRelocationTest.php new file mode 100644 index 00000000..48e2e390 --- /dev/null +++ b/tests/ScriptDirRelocationTest.php @@ -0,0 +1,152 @@ +name() . '-' . preg_replace('/[^A-Za-z0-9_-]+/', '_', (string) $this->dataName()); + $this->buildDir = __DIR__ . '/tmp/script-dir-relocation-' . $case . '-build'; + $this->runtimeDir = __DIR__ . '/tmp/script-dir-relocation-' . $case . '-runtime'; + @mkdir($this->buildDir, 0777, true); + (new Compiler())->compile(new FakeScriptDirModule(), $this->buildDir); + } + + public function testGeneratedScriptDirScriptContainsNoBakedPath(): void + { + $script = file_get_contents($this->buildDir . '/-Ray_Di_Annotation_ScriptDir.php'); + $this->assertSame("buildDir . '/-Ray_Compiler_Annotation_Compile.php'); + $this->assertSame("findGeneratedPhpFiles($this->buildDir); + $this->assertNotEmpty($phpFiles, 'expected at least one generated *.php script to be scanned'); + + // Collect every offender instead of asserting inside the loop, so a + // single run reports all leaking scripts, not just the first one. + $leaking = array_values(array_filter( + $phpFiles, + fn (string $path): bool => str_contains((string) file_get_contents($path), $this->buildDir), + )); + + $this->assertSame([], $leaking, 'these generated scripts must not contain the compile-time path: ' . implode(', ', $leaking)); + } + + /** @return list */ + private function findGeneratedPhpFiles(string $dir): array + { + $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS)); + $files = []; + foreach ($iterator as $file) { + $path = (string) $file; + if (str_ends_with($path, '.php')) { + $files[] = $path; + } + } + + return $files; + } + + /** @return array */ + public static function provideBindingNames(): array + { + return [ + 'constructor injection' => ['ctor'], + 'setter injection' => ['setter'], + 'provider' => ['provider'], + 'AOP-woven class' => ['aop'], + ]; + } + + #[DataProvider('provideBindingNames')] + public function testRelocatedBindingResolvesToTheRuntimeDir(string $bindingName): void + { + [$injector, $runtimeDir] = $this->relocateAndCreateInjector(); + + $instance = $injector->getInstance(FakeScriptDirConsumerInterface::class, $bindingName); + $this->assertInstanceOf(FakeScriptDirConsumerInterface::class, $instance); + $this->assertSame($runtimeDir, $instance->getScriptDir()); + } + + public function testRelocatedScriptDirInstanceBindingResolvesToTheRuntimeDir(): void + { + [$injector, $runtimeDir] = $this->relocateAndCreateInjector(); + + $scriptDir = $injector->getInstance('', ScriptDir::class); + $this->assertSame($runtimeDir, $scriptDir); + } + + public function testRelocatedInjectorInterfaceBindingIsUsable(): void + { + [$injector, $runtimeDir] = $this->relocateAndCreateInjector(); + + $innerInjector = $injector->getInstance(InjectorInterface::class); + $this->assertInstanceOf(CompiledInjector::class, $innerInjector); + + $instance = $innerInjector->getInstance(FakeScriptDirConsumerInterface::class, 'ctor'); + $this->assertSame($runtimeDir, $instance->getScriptDir()); + } + + /** @return array{0: CompiledInjector, 1: string} */ + private function relocateAndCreateInjector(): array + { + $this->assertTrue(rename($this->buildDir, $this->runtimeDir), 'failed to relocate the compiled script dir'); + + $runtimeDir = $this->runtimeDir; + assert($runtimeDir !== ''); + + $realRuntimeDir = realpath($runtimeDir); + $this->assertIsString($realRuntimeDir); + + return [new CompiledInjector($runtimeDir), $realRuntimeDir]; + } +}