Skip to content
2 changes: 1 addition & 1 deletion src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -939,4 +939,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo

return $node;
}
}
}
102 changes: 79 additions & 23 deletions src/Internal/CacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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;
}

Expand Down Expand Up @@ -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);
}
Expand Down
7 changes: 6 additions & 1 deletion src/Internal/ClassNameValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<Dog>", "array{id: int}", or unions.
*/
Expand All @@ -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;
Expand Down
57 changes: 48 additions & 9 deletions src/Internal/Config.php
Original file line number Diff line number Diff line change
Expand Up @@ -206,16 +206,23 @@ public static function get(): array
}

/** @var array<int, class-string<ExtensionInterface>> $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<string, mixed> $mergedConfig */
$mergedConfig = array_replace_recursive($defaultConfig, $userConfig);
// Append extension whitelist includes and stubs
/** @var array<int, string> $currentIncludes */
$currentIncludes = \is_array($mergedConfig['include'] ?? null) ? $mergedConfig['include'] : [];
/** @var array<int, string> $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);

Expand All @@ -229,8 +236,8 @@ public static function get(): array
*/
public static function set(array $config): void
{
/** @var array<string, mixed> $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<int, class-string<ExtensionInterface>> $configuredExtensions */
Expand All @@ -243,8 +250,8 @@ public static function set(array $config): void
/** @var array<int, string> $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;
Expand All @@ -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<string, mixed> $base
* @param array<string, mixed> $overrides
*
* @return array<string, mixed>
*/
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<string, bool> $baseInlineVars */
$baseInlineVars = $base['inline_vars'];
/** @var array<string, bool> $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.
*/
Expand Down
2 changes: 2 additions & 0 deletions src/Internal/PathMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

namespace TypePHP\Internal;

require_once __DIR__ . '/CacheManager.php';

/**
* Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
*
Expand Down
Loading
Loading