Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 121 additions & 7 deletions src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,72 @@ final class ContractParser
*/
private static array $classLevelDocCache = [];

/**
* Fast O(1) lookup matrix for scalar refinements compatible with PHP native built-in types.
*
* @var array<string, array<string, bool>>
*/
private const BUILTIN_REFINEMENTS = [
'int' => [
'positive-int' => true,
'negative-int' => true,
'non-positive-int' => true,
'non-negative-int' => true,
'non-zero-int' => true,
'unsigned-int' => true,
],
'integer' => [
'positive-int' => true,
'negative-int' => true,
'non-positive-int' => true,
'non-negative-int' => true,
'non-zero-int' => true,
'unsigned-int' => true,
],
'string' => [
'non-empty-string' => true,
'numeric-string' => true,
'lowercase-string' => true,
'non-empty-lowercase-string' => true,
'uppercase-string' => true,
'non-empty-uppercase-string' => true,
'class-string' => true,
'interface-string' => true,
'trait-string' => true,
'enum-string' => true,
'callable-string' => true,
'literal-string' => true,
'truthy-string' => true,
'non-falsy-string' => true,
],
'float' => [
'positive-float' => true,
'negative-float' => true,
'non-positive-float' => true,
'non-negative-float' => true,
'non-zero-float' => true,
'double' => true,
],
'double' => [
'positive-float' => true,
'negative-float' => true,
'non-positive-float' => true,
'non-negative-float' => true,
'non-zero-float' => true,
'double' => true,
],
'bool' => [
'true' => true,
'false' => true,
'boolean' => true,
],
'boolean' => [
'true' => true,
'false' => true,
'boolean' => true,
],
];

/**
* Resets the contract, property, and class-level docblock caches.
*/
Expand Down Expand Up @@ -586,6 +652,14 @@ private static function parseFunction(\ReflectionFunction $ref): array
if ($returnTag !== null) {
$substitutedReturn = self::substituteAliases($returnTag->type, $aliases);
$returnType = SpecialTypeResolver::resolve($substitutedReturn, $ref);

if (
Config::isRespectNativeNullabilityEnabled()
&& self::returnTypeExplicitlyAllowsNull($ref)
&& ! self::typeContainsNull($returnType)
) {
$returnType = new NullableTypeNode($returnType);
}
}

return [
Expand Down Expand Up @@ -757,6 +831,14 @@ private static function parseMethodHierarchyDocs(
if ($returnTag !== null) {
$substitutedReturn = self::substituteAliases($returnTag->type, $aliases);
$returnType = SpecialTypeResolver::resolve($substitutedReturn, $hierRef);

if (
Config::isRespectNativeNullabilityEnabled()
&& self::returnTypeExplicitlyAllowsNull($ref)
&& ! self::typeContainsNull($returnType)
) {
$returnType = new NullableTypeNode($returnType);
}
}
}
}
Expand Down Expand Up @@ -877,13 +959,19 @@ private static function applyConstructorPromotionFallback(
*/
private static function isRefinementOfBuiltin(string $refinement, string $builtin): bool
{
return match ($builtin) {
'int', 'integer' => \in_array($refinement, ['positive-int', 'negative-int', 'non-positive-int', 'non-negative-int', 'non-zero-int', 'unsigned-int'], true) || str_starts_with($refinement, 'int<'),
'string' => \in_array($refinement, ['non-empty-string', 'numeric-string', 'lowercase-string', 'non-empty-lowercase-string', 'uppercase-string', 'non-empty-uppercase-string', 'class-string', 'interface-string', 'trait-string', 'enum-string', 'callable-string', 'literal-string', 'truthy-string', 'non-falsy-string'], true) || str_starts_with($refinement, 'class-string<'),
'float', 'double' => \in_array($refinement, ['positive-float', 'negative-float', 'non-positive-float', 'non-negative-float', 'non-zero-float', 'double'], true),
'bool', 'boolean' => \in_array($refinement, ['true', 'false', 'boolean'], true),
default => false,
};
if (isset(self::BUILTIN_REFINEMENTS[$builtin][$refinement])) {
return true;
}

if (($builtin === 'int' || $builtin === 'integer') && str_starts_with($refinement, 'int<')) {
return true;
}

if ($builtin === 'string' && str_starts_with($refinement, 'class-string<')) {
return true;
}

return false;
}

/**
Expand Down Expand Up @@ -912,6 +1000,32 @@ private static function parameterExplicitlyAllowsNull(\ReflectionParameter $p):
return false;
}

/**
* Checks if a reflection function or method explicitly declares a nullable native return type (excluding mixed, void, and never).
*/
private static function returnTypeExplicitlyAllowsNull(\ReflectionFunctionAbstract $ref): bool
{
if (! $ref->hasReturnType()) {
return false;
}

$type = $ref->getReturnType();
if ($type instanceof \ReflectionNamedType) {
$name = strtolower($type->getName());
if ($name === 'mixed' || $name === 'void' || $name === 'never') {
return false;
}

return $type->allowsNull();
}

if ($type instanceof \ReflectionUnionType) {
return $type->allowsNull();
}

return false;
}

/**
* Checks if a TypeNode already represents or contains null.
*/
Expand Down
64 changes: 35 additions & 29 deletions src/Internal/CacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,19 @@ final class CacheManager
*/
private static ?string $resolvedCacheDir = null;

/**
* Memoized verification state of the cache directory.
*/
private static ?bool $secureDirVerified = null;

public static function reset(): void
{
self::$resolvedCacheDir = null;
self::$secureDirVerified = null;
}

/**
* Returns the absolute path to the cache directory, isolating by system user and parallel test worker if using temp dir.
* Returns the absolute path to the cache directory, isolating by system user if using temp dir.
*/
public static function getCacheDir(): string
{
Expand Down Expand Up @@ -55,20 +61,7 @@ public static function getCacheDir(): string
$user = (string) getmyuid();
}

$testToken = getenv('TEST_TOKEN');
$uniqueToken = getenv('UNIQUE_TEST_TOKEN');
$pestWorkerId = getenv('PEST_PARALLEL_WORKER_ID');

$workerToken = '0';
if (\is_string($testToken) && $testToken !== '') {
$workerToken = $testToken;
} elseif (\is_string($uniqueToken) && $uniqueToken !== '') {
$workerToken = $uniqueToken;
} elseif (\is_string($pestWorkerId) && $pestWorkerId !== '') {
$workerToken = $pestWorkerId;
}

$userHash = hash('xxh128', 'typephp_' . $user . '_w' . $workerToken);
$userHash = hash('xxh128', 'typephp_' . $user);

return self::$resolvedCacheDir = sys_get_temp_dir() . '/typephp-cache-' . $userHash;
}
Expand All @@ -93,50 +86,65 @@ public static function getCachedFilePath(string $resolvedPath): string
}

/**
* Ensures the cache directory exists securely with strict 0700 ownership.
* Ensures the cache directory exists securely.
* Enforces strict UID/0700 checks on system temp dirs, while allowing
* standard application permissions on custom user-configured directories.
*/
public static function ensureSecureCacheDir(): bool
{
if (self::$secureDirVerified !== null) {
return self::$secureDirVerified;
}

$cacheDir = self::getCacheDir();

if (is_link($cacheDir)) {
return false;
return self::$secureDirVerified = false;
}

$config = Config::get();
$isCustomDir = \is_string($config['cache_dir'] ?? null) && $config['cache_dir'] !== '';

if (! is_dir($cacheDir)) {
if (! @mkdir($cacheDir, 0700, recursive: true) && ! is_dir($cacheDir)) {
return false;
$mode = $isCustomDir ? 0775 : 0700;
if (! @mkdir($cacheDir, $mode, recursive: true) && ! is_dir($cacheDir)) {
return self::$secureDirVerified = false;
}
if (! $isCustomDir) {
@chmod($cacheDir, 0700);
}
@chmod($cacheDir, 0700);
}

if (\function_exists('posix_geteuid')) {
if (! $isCustomDir && \function_exists('posix_geteuid')) {
$owner = @fileowner($cacheDir);
if ($owner !== false && $owner !== posix_geteuid()) {
return false;
return self::$secureDirVerified = false;
}
}

return true;
return self::$secureDirVerified = is_writable($cacheDir);
}

/**
* Safely writes cached content atomically to avoid symlink traversal attacks.
* Safely writes cached content atomically to avoid corruption or partial reads.
*/
public static function writeCachedFileSafely(string $cachedFile, string $transformed): bool
{
if (! self::ensureSecureCacheDir()) {
return false;
}

$config = Config::get();
$isCustomDir = \is_string($config['cache_dir'] ?? null) && $config['cache_dir'] !== '';

$cacheDir = \dirname($cachedFile);
$tmpFile = $cacheDir . '/.tmp_' . bin2hex(random_bytes(8));

if (@file_put_contents($tmpFile, $transformed, LOCK_EX) === false) {
if (@file_put_contents($tmpFile, $transformed) === false) {
return false;
}

@chmod($tmpFile, 0600);
@chmod($tmpFile, $isCustomDir ? 0664 : 0600);

if (! @rename($tmpFile, $cachedFile)) {
@unlink($tmpFile);
Expand All @@ -148,8 +156,7 @@ public static function writeCachedFileSafely(string $cachedFile, string $transfo
}

/**
* Clears all cached transformed files from the cache directory,
* including all parallel worker directories (_w1, _w2, etc.).
* Clears all cached transformed files from the cache directory.
*/
public static function clear(): int
{
Expand Down Expand Up @@ -193,7 +200,6 @@ public static function clear(): int
}
}

// Also clean up any lingering temporary swap files
$tmpFiles = glob($dir . '/.tmp_*');
if ($tmpFiles !== false) {
foreach ($tmpFiles as $tFile) {
Expand Down
Loading
Loading