Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
120 changes: 89 additions & 31 deletions src/Internal/PathMatcher.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
namespace TypePHP\Internal;

/**
* @internal Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
* Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
*
* @internal
*/
final class PathMatcher
{
Expand Down Expand Up @@ -41,7 +43,7 @@ final class PathMatcher
private static ?string $cachedLibSrcDir = null;

/**
* Resets compiled pattern and directory caches.
* Resets compiled pattern, prefix, and directory caches.
*/
public static function reset(): void
{
Expand All @@ -64,16 +66,60 @@ public static function normalizePath(string|false|null $path): string
return str_replace('\\', '/', $path);
}

/**
* Collapses relative directory traversals (..) into canonical paths.
* Preserves leading root slashes and root boundaries.
*/
public static function canonicalizePath(string $path): string
{
$path = str_replace('\\', '/', $path);
if (! str_contains($path, '..') && ! str_contains($path, '/.')) {
return $path;
}

$parts = explode('/', $path);
$absolutes = [];

foreach ($parts as $part) {
if ($part === '.' || ($part === '' && \count($absolutes) > 0)) {
continue;
}

if ($part === '') {
$absolutes[] = '';

continue;
}

if ($part === '..') {
if (\count($absolutes) > 0 && end($absolutes) !== '..' && end($absolutes) !== '') {
array_pop($absolutes);
} elseif (\count($absolutes) === 0 || end($absolutes) === '..') {
$absolutes[] = '..';
}
} else {
$absolutes[] = $part;
}
}

if ($absolutes === ['']) {
return '/';
}

return implode('/', $absolutes);
}

/**
* Determines whether a given path is located within a vendor directory.
*/
public static function isVendorPath(string $normalizedPath, string $rawPath = ''): bool
{
$normalizedRaw = self::normalizePath($rawPath);
$canon = self::canonicalizePath($normalizedPath);
$canonRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : '';

return str_starts_with($normalizedPath, 'vendor/')
|| str_contains($normalizedPath, '/vendor/')
|| ($normalizedRaw !== '' && (str_starts_with($normalizedRaw, 'vendor/') || str_contains($normalizedRaw, '/vendor/')));
return str_starts_with($canon, 'vendor/')
|| str_contains($canon, '/vendor/')
|| ($canonRaw !== '' && (str_starts_with($canonRaw, 'vendor/') || str_contains($canonRaw, '/vendor/')));
}

/**
Expand All @@ -85,11 +131,11 @@ public static function isCachePath(string $normalizedPath): bool
self::$cachedCacheDir = rtrim(self::normalizePath(CacheManager::getCacheDir()), '/') . '/';
}

return str_starts_with($normalizedPath, self::$cachedCacheDir);
return str_starts_with(self::canonicalizePath($normalizedPath), self::$cachedCacheDir);
}

/**
* Determines whether a path belongs to TypePHP's own internal engine source files.
* Determines whether a path belongs to TypePHP's internal engine source files.
*/
public static function isLibraryInternal(string $normalizedPath): bool
{
Expand All @@ -103,11 +149,13 @@ public static function isLibraryInternal(string $normalizedPath): bool
return false;
}

$canon = self::canonicalizePath($normalizedPath);

if (str_contains($libSrcDir, '/vendor/')) {
return str_starts_with($normalizedPath, $libSrcDir);
return str_starts_with($canon, $libSrcDir);
}

if (str_starts_with($normalizedPath, $libSrcDir)) {
if (str_starts_with($canon, $libSrcDir)) {
$internalDirs = [
$libSrcDir . 'Internal/',
$libSrcDir . 'Contract/',
Expand All @@ -122,7 +170,7 @@ public static function isLibraryInternal(string $normalizedPath): bool
];

foreach ($internalDirs as $dir) {
if (str_starts_with($normalizedPath, $dir)) {
if (str_starts_with($canon, $dir)) {
return true;
}
}
Expand Down Expand Up @@ -156,47 +204,50 @@ public static function hasIncludeMatchingPrefix(string $prefix, array $includes)
}

/**
* Determines whether a directory path is a dynamic writable cache/log directory.
* Determines whether a directory path is a dynamic writable cache, log, or storage directory.
*/
public static function isDynamicWritablePath(string $normalizedPath): bool
{
return str_contains($normalizedPath, '/var/cache/') || str_starts_with($normalizedPath, 'var/cache/')
|| str_contains($normalizedPath, '/var/log/') || str_starts_with($normalizedPath, 'var/log/')
|| str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')
|| str_contains($normalizedPath, '/cache/') || str_starts_with($normalizedPath, 'cache/');
$canon = self::canonicalizePath($normalizedPath);

return str_contains($canon, '/var/cache/') || str_starts_with($canon, 'var/cache/')
|| str_contains($canon, '/var/log/') || str_starts_with($canon, 'var/log/')
|| str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')
|| str_contains($canon, '/cache/') || str_starts_with($canon, 'cache/');
}

/**
* High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included,
* while respecting user whitelists for vendor, var, and storage directories.
* High-speed string pre-filter to reject non-application paths before executing regex matching.
*/
public static function mayPathBeIncluded(string $normalizedPath): bool
{
if (str_contains($normalizedPath, '/node_modules/') || str_starts_with($normalizedPath, 'node_modules/')) {
$canon = self::canonicalizePath($normalizedPath);

if (str_contains($canon, '/node_modules/') || str_starts_with($canon, 'node_modules/')) {
return false;
}

if (self::isCachePath($normalizedPath)) {
if (self::isCachePath($canon)) {
return false;
}

$config = Config::get();
/** @var array<int, string> $includes */
$includes = \is_array($config['include'] ?? null) ? $config['include'] : ['**'];

if (str_contains($normalizedPath, '/vendor/') || str_starts_with($normalizedPath, 'vendor/')) {
if (str_contains($canon, '/vendor/') || str_starts_with($canon, 'vendor/')) {
if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) {
return false;
}
}

if (str_contains($normalizedPath, '/var/') || str_starts_with($normalizedPath, 'var/')) {
if (str_contains($canon, '/var/') || str_starts_with($canon, 'var/')) {
if (! self::hasIncludeMatchingPrefix('var/', $includes)) {
return false;
}
}

if (str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')) {
if (str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')) {
if (! self::hasIncludeMatchingPrefix('storage/', $includes)) {
return false;
}
Expand All @@ -213,14 +264,19 @@ public static function compileGlobToRegex(string $glob, string $baseDir): string
$glob = self::normalizePath(trim($glob));
$isAbsolute = str_starts_with($glob, '/') || (bool) preg_match('#^[a-zA-Z]:/#', $glob);

$regex = preg_quote($glob, '#');
$regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex);

if ($isAbsolute) {
$regex = preg_quote($glob, '#');
$regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex);
$pattern = '^' . $regex . '$';
} elseif ($glob === '*' || $glob === '**' || str_starts_with($glob, '**')) {
$pattern = '.*' . ($glob === '*' || $glob === '**' ? '' : substr($regex, 4)) . '$';
} elseif ($glob === '*' || $glob === '**') {
$pattern = '.*$';
} elseif (str_starts_with($glob, '**/')) {
$subRegex = preg_quote(substr($glob, 3), '#');
$subRegex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $subRegex);
$pattern = '(^|.*\/)' . $subRegex . '$';
} else {
$regex = preg_quote($glob, '#');
$regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex);
$pattern = '(^' . preg_quote($baseDir . '/', '#') . '|^)' . $regex . '$';
}

Expand All @@ -241,7 +297,8 @@ public static function isPathIncluded(
?string $baseDir = null
): bool {
$baseDir = $baseDir !== null ? self::normalizePath($baseDir) : Config::getProjectRoot();
$normalizedRaw = self::normalizePath($rawPath);
$normalizedPath = self::canonicalizePath($normalizedPath);
$normalizedRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : '';

$includes = self::getCompiledPatterns($includeGlobs, $baseDir, 'include');
$excludes = self::getCompiledPatterns($excludeGlobs, $baseDir, 'exclude');
Expand All @@ -250,7 +307,8 @@ public static function isPathIncluded(
if ($isVendor) {
$hasExplicitVendorWhitelist = false;
foreach ($includes as $compiled) {
if (str_starts_with($compiled['pattern'], 'vendor/') &&
if (
str_starts_with($compiled['pattern'], 'vendor/') &&
(preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1))
) {
$hasExplicitVendorWhitelist = true;
Expand Down Expand Up @@ -327,4 +385,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin

return self::$compiledExcludesCache[$cacheKey] = $compiled;
}
}
}
27 changes: 13 additions & 14 deletions src/Internal/StreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ public static function reset(): void
}

/**
* Registers the stream wrapper for the native 'file://' protocol.
*
* @param array<string, mixed> $config
*/
public static function register(array $config = []): void
Expand Down Expand Up @@ -120,10 +122,10 @@ public static function unregister(): void

/**
* Transforms PHP source code by parsing AST, extracting metadata, applying ContractVisitor, and formatting output.
* Preserves exact line numbers to guarantee zero line-drift in debug stack traces.
*/
public static function transformSource(string $source, string $filePath = ''): string
{
// Respect per-file suppression tag unless respect_ignore_tags is false
if (Config::isRespectIgnoreTagsEnabled() && (str_contains($source, '@typephp-ignore-file') || str_contains($source, '@typephp-disable-file'))) {
return $source;
}
Expand All @@ -150,14 +152,10 @@ public static function transformSource(string $source, string $filePath = ''): s

/** @var array<\PhpParser\Node\Stmt> $nodesToTraverse */
$nodesToTraverse = $oldStmts;

/** @var array<\PhpParser\Node\Stmt> $newStmts */
$newStmts = $traverser1->traverse($nodesToTraverse);

$traverser2 = new NodeTraverser();
$traverser2->addVisitor(new ContractVisitor());

/** @var array<\PhpParser\Node\Stmt> $newStmts */
$newStmts = $traverser2->traverse($newStmts);

$printer = new TypePHPPrinter();
Expand All @@ -184,13 +182,12 @@ public static function transformSource(string $source, string $filePath = ''): s
$transformed = preg_replace('/\/\*__TYPEPHP_INJECTED_END__\*\/[ \t]*\r?\n[ \t]*/', '/*__TYPEPHP_INJECTED_END__*/ ', $transformed, $drift) ?? $transformed;
}

$transformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed);

return $transformed;
return str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed);
}

/**
* Opens a file stream, intercepting application files for AST transformation.
* Opens a file stream, intercepting matching application files for AST transformation.
* Evaluates string fast-paths before checking file existence or unregistering the wrapper.
*/
public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool
{
Expand Down Expand Up @@ -395,11 +392,13 @@ public function stream_close(): void
}

/**
* High-speed stat resolution with $O(1)$ memoization cache.
* Caches positive stat hits.
* Only caches negative misses for STATIC directories (vendor, tests).
* NEVER caches negative misses for dynamic writable paths (var/cache, storage),
* guaranteeing Symfony/Shopware cache creation is detected immediately.
* High-speed stat resolution with dual-tier memoization caching:
*
* 1. Positive hit cache ($statCache): Caches stat arrays for existing files and directories.
* 2. Static negative cache ($staticNegativeStatCache): Caches false lookups strictly for static
* read-only paths (e.g. vendor directories), eliminating thousands of duplicate C-level stat calls.
* 3. Dynamic writable bypass: Never caches false for dynamic directories (var/cache, storage),
* ensuring framework cache warmers and runtime directory creation remain fully functional.
*
* @return array<int|string, int>|false
*/
Expand Down
Loading
Loading