diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index 53fa7f9..0ed3526 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -939,4 +939,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} \ No newline at end of file +} diff --git a/src/Internal/CacheManager.php b/src/Internal/CacheManager.php index 253bb8c..9a48cb1 100644 --- a/src/Internal/CacheManager.php +++ b/src/Internal/CacheManager.php @@ -17,14 +17,33 @@ final class CacheManager public const VERSION_PREFIX = 'v0.1_'; /** - * Returns the absolute path to the cache directory. + * Returns the absolute path to the cache directory, isolating by system user if using temp dir. */ public static function getCacheDir(): string { $config = Config::get(); $dir = $config['cache_dir'] ?? null; - return \is_string($dir) ? $dir : (sys_get_temp_dir() . '/typephp-cache'); + if (\is_string($dir) && $dir !== '') { + return $dir; + } + + $username = getenv('USERNAME'); + $userEnv = getenv('USER'); + + if (\function_exists('posix_geteuid')) { + $user = (string) posix_geteuid(); + } elseif (\is_string($username) && $username !== '') { + $user = $username; + } elseif (\is_string($userEnv) && $userEnv !== '') { + $user = $userEnv; + } else { + $user = (string) getmyuid(); + } + + $userHash = hash('xxh128', 'typephp_' . $user); + + return sys_get_temp_dir() . '/typephp-cache-' . $userHash; } /** @@ -47,44 +66,84 @@ public static function getCachedFilePath(string $resolvedPath): string } /** - * Clears all cached transformed files from the cache directory. + * Ensures the cache directory exists securely with strict 0700 ownership. */ - public static function clear(): int + public static function ensureSecureCacheDir(): bool { - $wasRegistered = StreamWrapper::isRegistered(); - StreamWrapper::unregister(); - $cacheDir = self::getCacheDir(); + if (is_link($cacheDir)) { + return false; + } + if (! is_dir($cacheDir)) { - if ($wasRegistered) { - StreamWrapper::register(); + if (! @mkdir($cacheDir, 0700, recursive: true) && ! is_dir($cacheDir)) { + return false; } + @chmod($cacheDir, 0700); + } + + if (\function_exists('posix_geteuid')) { + $owner = @fileowner($cacheDir); + if ($owner !== false && $owner !== posix_geteuid()) { + return false; + } + } + + return true; + } + + /** + * Safely writes cached content atomically to avoid symlink traversal attacks. + */ + public static function writeCachedFileSafely(string $cachedFile, string $transformed): bool + { + if (! self::ensureSecureCacheDir()) { + return false; + } + $cacheDir = \dirname($cachedFile); + $tmpFile = $cacheDir . '/.tmp_' . bin2hex(random_bytes(8)); + + if (@file_put_contents($tmpFile, $transformed, LOCK_EX) === false) { + return false; + } + + @chmod($tmpFile, 0600); + + if (! @rename($tmpFile, $cachedFile)) { + @unlink($tmpFile); + + return false; + } + + return true; + } + + /** + * Clears all cached transformed files from the cache directory. + */ + public static function clear(): int + { + $cacheDir = self::getCacheDir(); + + if (! is_dir($cacheDir) || is_link($cacheDir)) { return 0; } $files = glob($cacheDir . '/*.php'); if ($files === false || \count($files) === 0) { - if ($wasRegistered) { - StreamWrapper::register(); - } - return 0; } $count = 0; foreach ($files as $file) { - if (is_file($file)) { + if (is_file($file) && ! is_link($file)) { @unlink($file); $count++; } } - if ($wasRegistered) { - StreamWrapper::register(); - } - return $count; } @@ -117,12 +176,9 @@ public static function warmUp(?callable $progressCallback = null): array $source = file_get_contents($file); if ($source !== false) { $transformed = StreamWrapper::transformSource($source, $file); - $cacheDir = self::getCacheDir(); - if (! is_dir($cacheDir)) { - @mkdir($cacheDir, 0777, recursive: true); + if (self::writeCachedFileSafely($cachedFile, $transformed)) { + $cached++; } - file_put_contents($cachedFile, $transformed); - $cached++; if ($progressCallback !== null) { $progressCallback('cached', $file, $idx + 1, $total); } diff --git a/src/Internal/ClassNameValidator.php b/src/Internal/ClassNameValidator.php index 0eb0d66..7723aa1 100644 --- a/src/Internal/ClassNameValidator.php +++ b/src/Internal/ClassNameValidator.php @@ -10,7 +10,8 @@ final class ClassNameValidator { /** - * Validates whether a given value is a syntactically valid PHP class, interface, trait, or enum identifier. + * Validates whether a given value is a syntactically valid PHP class, interface, trait, or enum identifier, + * or a valid anonymous class name registered in memory. * Handles fully-qualified names with leading backslashes. * Returns false for non-strings, empty strings, complex PHPDoc strings like "Producer", "array{id: int}", or unions. */ @@ -20,6 +21,10 @@ public static function isValid(mixed $name): bool return false; } + if (str_contains($name, '@anonymous')) { + return class_exists($name, false); + } + $trimmed = ltrim($name, '\\'); if ($trimmed === '') { return false; diff --git a/src/Internal/Config.php b/src/Internal/Config.php index a6fb5a1..76baf96 100644 --- a/src/Internal/Config.php +++ b/src/Internal/Config.php @@ -206,16 +206,23 @@ public static function get(): array } /** @var array> $configuredExtensions */ - $configuredExtensions = \is_array($userConfig['extensions'] ?? null) ? $userConfig['extensions'] : []; + $configuredExtensions = \is_array($userConfig['extensions'] ?? null) + ? $userConfig['extensions'] + : $defaultConfig['extensions']; $extensionIncludes = ExtensionManager::loadExtensionIncludes($configuredExtensions); $extensionStubs = ExtensionManager::loadExtensionStubs($configuredExtensions); - $defaultConfig['include'] = array_unique(array_merge($defaultConfig['include'], $extensionIncludes)); - $defaultConfig['stubs'] = array_unique(array_merge($defaultConfig['stubs'], $extensionStubs)); + $mergedConfig = self::mergeConfig($defaultConfig, $userConfig); - /** @var array $mergedConfig */ - $mergedConfig = array_replace_recursive($defaultConfig, $userConfig); + // Append extension whitelist includes and stubs + /** @var array $currentIncludes */ + $currentIncludes = \is_array($mergedConfig['include'] ?? null) ? $mergedConfig['include'] : []; + /** @var array $currentStubs */ + $currentStubs = \is_array($mergedConfig['stubs'] ?? null) ? $mergedConfig['stubs'] : []; + + $mergedConfig['include'] = array_values(array_unique(array_merge($currentIncludes, $extensionIncludes))); + $mergedConfig['stubs'] = array_values(array_unique(array_merge($currentStubs, $extensionStubs))); self::syncFlags($mergedConfig); @@ -229,8 +236,8 @@ public static function get(): array */ public static function set(array $config): void { - /** @var array $mergedConfig */ - $mergedConfig = array_replace_recursive(self::get(), $config); + $current = self::$cachedConfig ?? self::get(); + $mergedConfig = self::mergeConfig($current, $config); if (isset($config['extensions']) && \is_array($config['extensions'])) { /** @var array> $configuredExtensions */ @@ -243,8 +250,8 @@ public static function set(array $config): void /** @var array $currentStubs */ $currentStubs = \is_array($mergedConfig['stubs'] ?? null) ? $mergedConfig['stubs'] : []; - $mergedConfig['include'] = array_unique(array_merge($currentIncludes, $extensionIncludes)); - $mergedConfig['stubs'] = array_unique(array_merge($currentStubs, $extensionStubs)); + $mergedConfig['include'] = array_values(array_unique(array_merge($currentIncludes, $extensionIncludes))); + $mergedConfig['stubs'] = array_values(array_unique(array_merge($currentStubs, $extensionStubs))); } self::$cachedConfig = $mergedConfig; @@ -259,6 +266,38 @@ public static function set(array $config): void StubManager::reset(); } + /** + * Merges user configuration over base defaults: + * - Associative dictionaries (inline_vars) are merged recursively. + * - Sequential lists (include, exclude, extensions, stubs) are REPLACED wholesale when defined. + * - Scalars / booleans / strings are overwritten. + * + * @param array $base + * @param array $overrides + * + * @return array + */ + private static function mergeConfig(array $base, array $overrides): array + { + $merged = $base; + + foreach ($overrides as $key => $value) { + if ($key === 'inline_vars' && \is_array($value) && isset($base['inline_vars']) && \is_array($base['inline_vars'])) { + /** @var array $baseInlineVars */ + $baseInlineVars = $base['inline_vars']; + /** @var array $overrideInlineVars */ + $overrideInlineVars = $value; + $merged['inline_vars'] = array_merge($baseInlineVars, $overrideInlineVars); + } elseif (\in_array($key, ['include', 'exclude', 'extensions', 'stubs'], true) && \is_array($value)) { + $merged[$key] = array_values($value); + } else { + $merged[$key] = $value; + } + } + + return $merged; + } + /** * Resets the configuration cache. Useful for test isolation. */ diff --git a/src/Internal/PathMatcher.php b/src/Internal/PathMatcher.php index 69e222a..bc37763 100644 --- a/src/Internal/PathMatcher.php +++ b/src/Internal/PathMatcher.php @@ -4,6 +4,8 @@ namespace TypePHP\Internal; +require_once __DIR__ . '/CacheManager.php'; + /** * Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching. * diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index a65a507..77e708d 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -44,8 +44,6 @@ final class StreamWrapper implements StreamWrapperInterface private static bool $cacheEnabled = true; - private static string $cacheDir = ''; - /** * In-memory cache for positive url_stat results. * @@ -103,7 +101,6 @@ public static function register(array $config = []): void $resolvedConfig = array_replace_recursive(Config::get(), $config); self::$cacheEnabled = (bool) ($resolvedConfig['cache'] ?? true); - self::$cacheDir = CacheManager::getCacheDir(); if (! self::$isRegistered) { stream_wrapper_unregister('file'); @@ -205,7 +202,6 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ return $this->openDirectHandle($path, $mode); } - if (! str_ends_with(strtolower($path), '.php')) { return $this->openDirectHandle($path, $mode); } @@ -220,8 +216,8 @@ public function stream_open(string $path, string $mode, int $options, ?string &$ } self::unregister(); - $exists = (bool) self::silent(fn() => file_exists($path)); - $resolvedPath = $exists ? self::silent(fn() => realpath($path)) : false; + $exists = (bool) self::silent(fn () => file_exists($path)); + $resolvedPath = $exists ? self::silent(fn () => realpath($path)) : false; self::register(); if (! $exists || $resolvedPath === false) { @@ -270,7 +266,7 @@ private function openDirectHandle(string $targetFile, string $mode): bool { self::unregister(); /** @var resource|false $handle */ - $handle = self::silent(fn() => fopen($targetFile, $mode)); + $handle = self::silent(fn () => fopen($targetFile, $mode)); $this->handle = $handle !== false ? $handle : null; self::register(); @@ -420,7 +416,7 @@ public function url_stat(string $path, int $flags): array|false self::unregister(); /** @var array|false $result */ - $result = self::silent(fn() => stat($path)); + $result = self::silent(fn () => stat($path)); self::register(); if ($result !== false) { @@ -446,11 +442,11 @@ public function stream_metadata(string $path, int $option, mixed $value): bool $valueArray = \is_array($value) ? $value : []; $time = $valueArray[0] ?? time(); $atime = $valueArray[1] ?? $time; - $result = (bool) self::silent(fn() => touch($path, (int) $time, (int) $atime)); + $result = (bool) self::silent(fn () => touch($path, (int) $time, (int) $atime)); } elseif ($option === STREAM_META_ACCESS) { /** @var int $mode */ $mode = \is_int($value) ? $value : 0777; - $result = (bool) self::silent(fn() => chmod($path, $mode)); + $result = (bool) self::silent(fn () => chmod($path, $mode)); } self::register(); @@ -461,7 +457,7 @@ public function dir_opendir(string $path, int $options): bool { self::unregister(); /** @var resource|false $dh */ - $dh = self::silent(fn() => opendir($path)); + $dh = self::silent(fn () => opendir($path)); $this->dirHandle = $dh !== false ? $dh : null; self::register(); @@ -504,7 +500,7 @@ public function mkdir(string $path, int $mode, int $options): bool unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn() => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); + $result = (bool) self::silent(fn () => mkdir($path, $mode, ($options & STREAM_MKDIR_RECURSIVE) !== 0)); self::register(); return $result; @@ -516,7 +512,7 @@ public function rmdir(string $path, int $options): bool unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn() => rmdir($path)); + $result = (bool) self::silent(fn () => rmdir($path)); self::register(); return $result; @@ -528,7 +524,7 @@ public function unlink(string $path): bool unset(self::$statCache[$normalized], self::$staticNegativeStatCache[$normalized]); self::unregister(); - $result = (bool) self::silent(fn() => unlink($path)); + $result = (bool) self::silent(fn () => unlink($path)); self::register(); return $result; @@ -546,7 +542,7 @@ public function rename(string $pathFrom, string $pathTo): bool ); self::unregister(); - $result = (bool) self::silent(fn() => rename($pathFrom, $pathTo)); + $result = (bool) self::silent(fn () => rename($pathFrom, $pathTo)); self::register(); return $result; @@ -563,7 +559,7 @@ public function rename(string $pathFrom, string $pathTo): bool */ private static function silent(callable $callback): mixed { - set_error_handler(fn() => true); + set_error_handler(fn () => true); try { return $callback(); @@ -634,18 +630,27 @@ private function openMemoryStream(string $resolvedPath): bool */ private function openCachedStream(string $resolvedPath, string $mode): bool { - $cacheDir = self::$cacheDir; - if (! is_dir($cacheDir)) { - self::silent(fn() => mkdir($cacheDir, 0777, true)); - } - $cachedFile = CacheManager::getCachedFilePath($resolvedPath); - if (! file_exists($cachedFile)) { + if (! CacheManager::ensureSecureCacheDir()) { + return $this->openMemoryStream($resolvedPath); + } + + if (! file_exists($cachedFile) || is_link($cachedFile)) { $source = file_get_contents($resolvedPath); - if ($source !== false) { - $transformed = self::transformSource($source, $resolvedPath); - file_put_contents($cachedFile, $transformed); + if ($source === false) { + return false; + } + $transformed = self::transformSource($source, $resolvedPath); + if (! CacheManager::writeCachedFileSafely($cachedFile, $transformed)) { + return $this->openMemoryStream($resolvedPath); + } + } + + if (\function_exists('posix_geteuid')) { + $owner = @fileowner($cachedFile); + if ($owner !== false && $owner !== posix_geteuid()) { + return $this->openMemoryStream($resolvedPath); } } diff --git a/src/Internal/Visitor/FunctionContractInjector.php b/src/Internal/Visitor/FunctionContractInjector.php index aab4c4d..c65a076 100644 --- a/src/Internal/Visitor/FunctionContractInjector.php +++ b/src/Internal/Visitor/FunctionContractInjector.php @@ -64,7 +64,7 @@ public static function inject(Node\Stmt\Function_|Node\Stmt\ClassMethod $node): private static function hasParamContracts(string $docText, bool $isClassMethod): bool { if ($isClassMethod) { - return true; + return true; } if (! str_contains($docText, '@param') && ! str_contains($docText, '@phpstan-param') && ! str_contains($docText, '@psalm-param') && ! str_contains($docText, '@template')) { @@ -82,6 +82,7 @@ private static function hasParamContracts(string $docText, bool $isClassMethod): foreach ($unionParts as $part) { if (strtolower(trim($part)) === 'mixed') { $hasMixed = true; + break; } } @@ -91,7 +92,7 @@ private static function hasParamContracts(string $docText, bool $isClassMethod): } } - return false; + return false; } return false; @@ -100,7 +101,7 @@ private static function hasParamContracts(string $docText, bool $isClassMethod): private static function hasReturnContracts(string $docText, bool $isClassMethod): bool { if ($isClassMethod) { - return true; + return true; } if (str_contains($docText, '@template') || str_contains($docText, '@phpstan-return') || str_contains($docText, '@psalm-return')) { @@ -653,4 +654,4 @@ public function enterNode(Node $n): int|array|null return $newStmts; } -} \ No newline at end of file +} diff --git a/src/Validator/IdentifierValidator.php b/src/Validator/IdentifierValidator.php index 4b47815..b05d9cd 100644 --- a/src/Validator/IdentifierValidator.php +++ b/src/Validator/IdentifierValidator.php @@ -52,7 +52,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali 'non-negative-float' => (\is_float($value) || \is_int($value)) && $value >= 0, 'non-zero-float' => (\is_float($value) || \is_int($value)) && $value !== 0 && $value !== 0.0, 'class-string' => \is_string($value) - && preg_match('/^[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff\\\\]*$/', $value) === 1 + && ClassNameValidator::isValid($value) && (class_exists($value) || interface_exists($value) || trait_exists($value) || enum_exists($value)), 'interface-string' => \is_string($value) && interface_exists($value), 'trait-string' => \is_string($value) && trait_exists($value), diff --git a/src/bootstrap.php b/src/bootstrap.php index 83fb62e..10f6491 100644 --- a/src/bootstrap.php +++ b/src/bootstrap.php @@ -10,20 +10,37 @@ $isDisabledEnv = getenv('TYPEPHP_DISABLE') !== false && filter_var(getenv('TYPEPHP_DISABLE'), FILTER_VALIDATE_BOOLEAN); $isDisabledConst = \defined('TYPEPHP_DISABLE') && TYPEPHP_DISABLE; - $argv = $_SERVER['argv'] ?? null; - $stringArgs = \is_array($argv) ? array_filter($argv, 'is_string') : []; - $allArgs = implode(' ', $stringArgs); - $script = (isset($_SERVER['SCRIPT_NAME']) && \is_string($_SERVER['SCRIPT_NAME'])) ? $_SERVER['SCRIPT_NAME'] : ''; - - $normalized = str_replace('\\', '/', strtolower($allArgs . ' ' . $script)); - - $isTooling = str_contains($normalized, 'phpstan') - || str_contains($normalized, 'psalm') - || str_contains($normalized, 'php-cs-fixer') - || str_contains($normalized, 'pint') - || str_contains($normalized, 'rector') - || str_contains($normalized, 'mago') - || str_contains($normalized, 'composer'); + $isTooling = false; + + if (isset($_SERVER['argv']) && \is_array($_SERVER['argv']) && \count($_SERVER['argv']) > 0) { + $candidate = $_SERVER['argv'][0]; + + if (\is_string($candidate)) { + $baseArg0 = strtolower(basename(str_replace('\\', '/', $candidate))); + if (\in_array($baseArg0, ['php', 'php.exe', 'php-cgi', 'php-fpm'], true) && isset($_SERVER['argv'][1]) && \is_string($_SERVER['argv'][1])) { + $candidate = $_SERVER['argv'][1]; + } + + $rawBinary = strtolower(basename(str_replace('\\', '/', $candidate))); + $binary = preg_replace('/\.(phar|bat|exe|cmd)$/i', '', $rawBinary) ?? $rawBinary; + + $toolingBinaries = [ + 'phpstan' => true, + 'psalm' => true, + 'php-cs-fixer' => true, + 'phpcs' => true, + 'phpcbf' => true, + 'pint' => true, + 'rector' => true, + 'mago' => true, + 'composer' => true, + 'deptrac' => true, + 'phan' => true, + ]; + + $isTooling = isset($toolingBinaries[$binary]); + } + } if (! $isDisabledEnv && ! $isDisabledConst && ! $isTooling) { TypePHP::boot(); diff --git a/tests/Contract/VendorIsolationPathTest.php b/tests/Contract/VendorIsolationPathTest.php index d80a37d..1e5b5f2 100644 --- a/tests/Contract/VendorIsolationPathTest.php +++ b/tests/Contract/VendorIsolationPathTest.php @@ -337,6 +337,7 @@ Config::set([ 'include' => [ 'src/**', + 'app/**', ], 'exclude' => [ 'vendor/**', diff --git a/tests/Internal/ClassNameValidatorTest.php b/tests/Internal/ClassNameValidatorTest.php index 27b1047..f71070a 100644 --- a/tests/Internal/ClassNameValidatorTest.php +++ b/tests/Internal/ClassNameValidatorTest.php @@ -34,4 +34,9 @@ expect(ClassNameValidator::isValid([]))->toBeFalse(); expect(ClassNameValidator::isValid(new stdClass()))->toBeFalse(); }); + + test('accepts anonymous class names registered in memory', function () { + $anon = new class () {}; + expect(ClassNameValidator::isValid($anon::class))->toBeTrue(); + }); }); diff --git a/tests/Internal/ConfigTest.php b/tests/Internal/ConfigTest.php index 428fa20..eed9914 100644 --- a/tests/Internal/ConfigTest.php +++ b/tests/Internal/ConfigTest.php @@ -63,4 +63,38 @@ ; } }); + + test('user include and exclude lists are replaced wholesale and do not leak default list entries', function () { + Config::set([ + 'include' => [ + 'src/**', + ], + 'exclude' => [ + 'vendor/**', + 'tests/**', + 'var/**', + ], + ]); + + $config = Config::get(); + + expect($config['include'])->toBe(['src/**']) + ->and($config['exclude'])->toBe(['vendor/**', 'tests/**', 'var/**']) + ; + }); + + test('inline_vars associative options are merged so single toggles can be overridden', function () { + Config::set([ + 'inline_vars' => [ + 'scalars' => false, + ], + ]); + + $config = Config::get(); + + expect($config['inline_vars']['scalars'])->toBeFalse() + ->and($config['inline_vars']['properties'])->toBeTrue() + ->and($config['inline_vars']['generics'])->toBeTrue() + ; + }); }); diff --git a/tests/Internal/StreamWrapperTest.php b/tests/Internal/StreamWrapperTest.php index 0085d7a..2bac199 100644 --- a/tests/Internal/StreamWrapperTest.php +++ b/tests/Internal/StreamWrapperTest.php @@ -303,7 +303,8 @@ function sampleAction(int $id): string expect($readSource)->toBe($rawSource) ->and($readSource)->not()->toContain('RuntimeTypeChecker::setupScope') - ->and($readSource)->not()->toContain('RuntimeTypeChecker::checkReturn'); + ->and($readSource)->not()->toContain('RuntimeTypeChecker::checkReturn') + ; $fp = fopen($testFile, 'r'); expect($fp)->not()->toBeFalse(); @@ -311,13 +312,14 @@ function sampleAction(int $id): string fclose($fp); expect($streamContent)->toBe($rawSource) - ->and($streamContent)->not()->toContain('RuntimeTypeChecker::setupScope'); + ->and($streamContent)->not()->toContain('RuntimeTypeChecker::setupScope') + ; require $testFile; expect(\App\Test\sampleAction(42))->toBe('id_42'); - expect(fn () => \App\Test\sampleAction(-5)) + expect(fn() => \App\Test\sampleAction(-5)) ->toThrow(TypeError::class, 'positive-int'); } finally { if (file_exists($testFile)) { @@ -344,7 +346,8 @@ function sampleAction(int $id): string $bytesWritten = file_put_contents($testFile, $payload); expect($bytesWritten)->toBe(\strlen($payload)) - ->and(file_get_contents($testFile))->toBe($payload); + ->and(file_get_contents($testFile))->toBe($payload) + ; } finally { if (file_exists($testFile)) { @unlink($testFile); @@ -358,21 +361,64 @@ function sampleAction(int $id): string test('stream_open options flag differentiates include (128) from normal read (0)', function () { StreamWrapper::register(); - $wrapper = new StreamWrapper(); - $openedPath = null; - $testFile = str_replace('\\', '/', realpath(__DIR__ . '/../../tests/Fixtures/Services/HelperService.php') ?: ''); + $sysTemp = realpath(sys_get_temp_dir()); + $baseTemp = str_replace('\\', '/', $sysTemp !== false ? $sysTemp : sys_get_temp_dir()); + $tempDir = $baseTemp . '/typephp_stream_opt_' . uniqid(); + mkdir($tempDir, 0777, true); - $wrapper->stream_open($testFile, 'r', 0, $openedPath); - $rawContent = $wrapper->stream_read(5000); - $wrapper->stream_close(); + $canonicalDir = str_replace('\\', '/', realpath($tempDir) ?: $tempDir); + $testFile = $canonicalDir . '/DedicatedStreamTest.php'; - expect($rawContent)->not()->toContain('RuntimeTypeChecker::setupScope'); + $rawSource = <<<'PHP' +stream_open($testFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); - $transformedContent = $wrapper->stream_read(5000); - $wrapper->stream_close(); +declare(strict_types=1); + +namespace App\StreamTest; + +/** + * @param positive-int $id + */ +function dedicatedStreamAction(int $id): int +{ + return $id; +} +PHP; + file_put_contents($testFile, $rawSource); + + try { + Config::set([ + 'include' => [ + $canonicalDir . '/**', + ], + 'exclude' => [ + 'vendor/**', + ], + ]); + + $wrapper = new StreamWrapper(); + $openedPath = null; - expect($transformedContent)->toContain('RuntimeTypeChecker::setupScope'); + $wrapper->stream_open($testFile, 'r', 0, $openedPath); + $rawContent = $wrapper->stream_read(5000); + $wrapper->stream_close(); + + expect($rawContent)->not()->toContain('RuntimeTypeChecker::setupScope') + ->and($rawContent)->toBe($rawSource); + + $wrapper->stream_open($testFile, 'r', StreamWrapper::STREAM_OPEN_FOR_INCLUDE, $openedPath); + $transformedContent = $wrapper->stream_read(5000); + $wrapper->stream_close(); + + expect($transformedContent)->toContain('RuntimeTypeChecker::setupScope'); + } finally { + if (file_exists($testFile)) { + @unlink($testFile); + } + if (is_dir($tempDir)) { + @rmdir($tempDir); + } + } }); }); -}); \ No newline at end of file +}); diff --git a/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php b/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php index a41aca8..d4cead2 100644 --- a/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php +++ b/tests/TypeChecking/Scalars/ClassStringInterfaceTest.php @@ -25,4 +25,17 @@ ->toThrow(TypeError::class, 'must be a class-string of Countable') ; }); + + test('accepts anonymous class implementing interface for class-string', function () { + $anonCountable = new class () implements Countable { + public function count(): int + { + return 0; + } + }; + + expect(ClassStringFactoryContainer::makeCountable($anonCountable::class)) + ->toBe($anonCountable::class) + ; + }); }); diff --git a/tests/Unit/BootstrapToolingDetectionTest.php b/tests/Unit/BootstrapToolingDetectionTest.php new file mode 100644 index 0000000..65842c9 --- /dev/null +++ b/tests/Unit/BootstrapToolingDetectionTest.php @@ -0,0 +1,64 @@ + true, + 'psalm' => true, + 'php-cs-fixer' => true, + 'pint' => true, + 'rector' => true, + 'mago' => true, + 'composer' => true, + ]; + + expect(isset($toolingBinaries[$binary]))->toBeFalse(); + }); + + test('accurately detects actual tooling binary executions', function () { + $tools = [ + ['vendor/bin/phpstan', 'analyse'], + ['/usr/local/bin/composer', 'install'], + ['php', 'vendor/bin/rector', 'process'], + ['vendor/bin/pint.phar'], + ['/usr/bin/psalm'], + ['vendor/bin/php-cs-fixer.bat'], + ]; + + $toolingBinaries = [ + 'phpstan' => true, + 'psalm' => true, + 'php-cs-fixer' => true, + 'pint' => true, + 'rector' => true, + 'mago' => true, + 'composer' => true, + ]; + + foreach ($tools as $argv) { + $candidate = $argv[0]; + $baseArg0 = strtolower(basename(str_replace('\\', '/', $candidate))); + if (\in_array($baseArg0, ['php', 'php.exe'], true) && isset($argv[1])) { + $candidate = $argv[1]; + } + + $rawBinary = strtolower(basename(str_replace('\\', '/', $candidate))); + $binary = preg_replace('/\.(phar|bat|exe|cmd)$/i', '', $rawBinary) ?? $rawBinary; + + expect(isset($toolingBinaries[$binary]))->toBeTrue(); + } + }); +});