Skip to content

Commit 7a21ea1

Browse files
authored
Enhance PathMatcher with canonicalizePath method and improve unit tests; update StreamWrapper documentation for clarity (#42)
1 parent 7ab3e7f commit 7a21ea1

3 files changed

Lines changed: 229 additions & 49 deletions

File tree

src/Internal/PathMatcher.php

Lines changed: 89 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
namespace TypePHP\Internal;
66

77
/**
8-
* @internal Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
8+
* Centralized utility for path normalization, glob compilation, vendor isolation, and specificity matching.
9+
*
10+
* @internal
911
*/
1012
final class PathMatcher
1113
{
@@ -41,7 +43,7 @@ final class PathMatcher
4143
private static ?string $cachedLibSrcDir = null;
4244

4345
/**
44-
* Resets compiled pattern and directory caches.
46+
* Resets compiled pattern, prefix, and directory caches.
4547
*/
4648
public static function reset(): void
4749
{
@@ -64,16 +66,60 @@ public static function normalizePath(string|false|null $path): string
6466
return str_replace('\\', '/', $path);
6567
}
6668

69+
/**
70+
* Collapses relative directory traversals (..) into canonical paths.
71+
* Preserves leading root slashes and root boundaries.
72+
*/
73+
public static function canonicalizePath(string $path): string
74+
{
75+
$path = str_replace('\\', '/', $path);
76+
if (! str_contains($path, '..') && ! str_contains($path, '/.')) {
77+
return $path;
78+
}
79+
80+
$parts = explode('/', $path);
81+
$absolutes = [];
82+
83+
foreach ($parts as $part) {
84+
if ($part === '.' || ($part === '' && \count($absolutes) > 0)) {
85+
continue;
86+
}
87+
88+
if ($part === '') {
89+
$absolutes[] = '';
90+
91+
continue;
92+
}
93+
94+
if ($part === '..') {
95+
if (\count($absolutes) > 0 && end($absolutes) !== '..' && end($absolutes) !== '') {
96+
array_pop($absolutes);
97+
} elseif (\count($absolutes) === 0 || end($absolutes) === '..') {
98+
$absolutes[] = '..';
99+
}
100+
} else {
101+
$absolutes[] = $part;
102+
}
103+
}
104+
105+
if ($absolutes === ['']) {
106+
return '/';
107+
}
108+
109+
return implode('/', $absolutes);
110+
}
111+
67112
/**
68113
* Determines whether a given path is located within a vendor directory.
69114
*/
70115
public static function isVendorPath(string $normalizedPath, string $rawPath = ''): bool
71116
{
72-
$normalizedRaw = self::normalizePath($rawPath);
117+
$canon = self::canonicalizePath($normalizedPath);
118+
$canonRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : '';
73119

74-
return str_starts_with($normalizedPath, 'vendor/')
75-
|| str_contains($normalizedPath, '/vendor/')
76-
|| ($normalizedRaw !== '' && (str_starts_with($normalizedRaw, 'vendor/') || str_contains($normalizedRaw, '/vendor/')));
120+
return str_starts_with($canon, 'vendor/')
121+
|| str_contains($canon, '/vendor/')
122+
|| ($canonRaw !== '' && (str_starts_with($canonRaw, 'vendor/') || str_contains($canonRaw, '/vendor/')));
77123
}
78124

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

88-
return str_starts_with($normalizedPath, self::$cachedCacheDir);
134+
return str_starts_with(self::canonicalizePath($normalizedPath), self::$cachedCacheDir);
89135
}
90136

91137
/**
92-
* Determines whether a path belongs to TypePHP's own internal engine source files.
138+
* Determines whether a path belongs to TypePHP's internal engine source files.
93139
*/
94140
public static function isLibraryInternal(string $normalizedPath): bool
95141
{
@@ -103,11 +149,13 @@ public static function isLibraryInternal(string $normalizedPath): bool
103149
return false;
104150
}
105151

152+
$canon = self::canonicalizePath($normalizedPath);
153+
106154
if (str_contains($libSrcDir, '/vendor/')) {
107-
return str_starts_with($normalizedPath, $libSrcDir);
155+
return str_starts_with($canon, $libSrcDir);
108156
}
109157

110-
if (str_starts_with($normalizedPath, $libSrcDir)) {
158+
if (str_starts_with($canon, $libSrcDir)) {
111159
$internalDirs = [
112160
$libSrcDir . 'Internal/',
113161
$libSrcDir . 'Contract/',
@@ -122,7 +170,7 @@ public static function isLibraryInternal(string $normalizedPath): bool
122170
];
123171

124172
foreach ($internalDirs as $dir) {
125-
if (str_starts_with($normalizedPath, $dir)) {
173+
if (str_starts_with($canon, $dir)) {
126174
return true;
127175
}
128176
}
@@ -156,47 +204,50 @@ public static function hasIncludeMatchingPrefix(string $prefix, array $includes)
156204
}
157205

158206
/**
159-
* Determines whether a directory path is a dynamic writable cache/log directory.
207+
* Determines whether a directory path is a dynamic writable cache, log, or storage directory.
160208
*/
161209
public static function isDynamicWritablePath(string $normalizedPath): bool
162210
{
163-
return str_contains($normalizedPath, '/var/cache/') || str_starts_with($normalizedPath, 'var/cache/')
164-
|| str_contains($normalizedPath, '/var/log/') || str_starts_with($normalizedPath, 'var/log/')
165-
|| str_contains($normalizedPath, '/storage/') || str_starts_with($normalizedPath, 'storage/')
166-
|| str_contains($normalizedPath, '/cache/') || str_starts_with($normalizedPath, 'cache/');
211+
$canon = self::canonicalizePath($normalizedPath);
212+
213+
return str_contains($canon, '/var/cache/') || str_starts_with($canon, 'var/cache/')
214+
|| str_contains($canon, '/var/log/') || str_starts_with($canon, 'var/log/')
215+
|| str_contains($canon, '/storage/') || str_starts_with($canon, 'storage/')
216+
|| str_contains($canon, '/cache/') || str_starts_with($canon, 'cache/');
167217
}
168218

169219
/**
170-
* High-speed $O(1)$ string pre-filter to determine if a raw path can possibly be included,
171-
* while respecting user whitelists for vendor, var, and storage directories.
220+
* High-speed string pre-filter to reject non-application paths before executing regex matching.
172221
*/
173222
public static function mayPathBeIncluded(string $normalizedPath): bool
174223
{
175-
if (str_contains($normalizedPath, '/node_modules/') || str_starts_with($normalizedPath, 'node_modules/')) {
224+
$canon = self::canonicalizePath($normalizedPath);
225+
226+
if (str_contains($canon, '/node_modules/') || str_starts_with($canon, 'node_modules/')) {
176227
return false;
177228
}
178229

179-
if (self::isCachePath($normalizedPath)) {
230+
if (self::isCachePath($canon)) {
180231
return false;
181232
}
182233

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

187-
if (str_contains($normalizedPath, '/vendor/') || str_starts_with($normalizedPath, 'vendor/')) {
238+
if (str_contains($canon, '/vendor/') || str_starts_with($canon, 'vendor/')) {
188239
if (! self::hasIncludeMatchingPrefix('vendor/', $includes)) {
189240
return false;
190241
}
191242
}
192243

193-
if (str_contains($normalizedPath, '/var/') || str_starts_with($normalizedPath, 'var/')) {
244+
if (str_contains($canon, '/var/') || str_starts_with($canon, 'var/')) {
194245
if (! self::hasIncludeMatchingPrefix('var/', $includes)) {
195246
return false;
196247
}
197248
}
198249

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

216-
$regex = preg_quote($glob, '#');
217-
$regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex);
218-
219267
if ($isAbsolute) {
268+
$regex = preg_quote($glob, '#');
269+
$regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex);
220270
$pattern = '^' . $regex . '$';
221-
} elseif ($glob === '*' || $glob === '**' || str_starts_with($glob, '**')) {
222-
$pattern = '.*' . ($glob === '*' || $glob === '**' ? '' : substr($regex, 4)) . '$';
271+
} elseif ($glob === '*' || $glob === '**') {
272+
$pattern = '.*$';
273+
} elseif (str_starts_with($glob, '**/')) {
274+
$subRegex = preg_quote(substr($glob, 3), '#');
275+
$subRegex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $subRegex);
276+
$pattern = '(^|.*\/)' . $subRegex . '$';
223277
} else {
278+
$regex = preg_quote($glob, '#');
279+
$regex = str_replace(['\*\*', '\*'], ['.*', '[^/]*'], $regex);
224280
$pattern = '(^' . preg_quote($baseDir . '/', '#') . '|^)' . $regex . '$';
225281
}
226282

@@ -241,7 +297,8 @@ public static function isPathIncluded(
241297
?string $baseDir = null
242298
): bool {
243299
$baseDir = $baseDir !== null ? self::normalizePath($baseDir) : Config::getProjectRoot();
244-
$normalizedRaw = self::normalizePath($rawPath);
300+
$normalizedPath = self::canonicalizePath($normalizedPath);
301+
$normalizedRaw = $rawPath !== '' ? self::canonicalizePath(self::normalizePath($rawPath)) : '';
245302

246303
$includes = self::getCompiledPatterns($includeGlobs, $baseDir, 'include');
247304
$excludes = self::getCompiledPatterns($excludeGlobs, $baseDir, 'exclude');
@@ -250,7 +307,8 @@ public static function isPathIncluded(
250307
if ($isVendor) {
251308
$hasExplicitVendorWhitelist = false;
252309
foreach ($includes as $compiled) {
253-
if (str_starts_with($compiled['pattern'], 'vendor/') &&
310+
if (
311+
str_starts_with($compiled['pattern'], 'vendor/') &&
254312
(preg_match($compiled['regex'], $normalizedPath) === 1 || ($normalizedRaw !== '' && preg_match($compiled['regex'], $normalizedRaw) === 1))
255313
) {
256314
$hasExplicitVendorWhitelist = true;
@@ -327,4 +385,4 @@ private static function getCompiledPatterns(array $globs, string $baseDir, strin
327385

328386
return self::$compiledExcludesCache[$cacheKey] = $compiled;
329387
}
330-
}
388+
}

src/Internal/StreamWrapper.php

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ public static function reset(): void
8989
}
9090

9191
/**
92+
* Registers the stream wrapper for the native 'file://' protocol.
93+
*
9294
* @param array<string, mixed> $config
9395
*/
9496
public static function register(array $config = []): void
@@ -120,10 +122,10 @@ public static function unregister(): void
120122

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

151153
/** @var array<\PhpParser\Node\Stmt> $nodesToTraverse */
152154
$nodesToTraverse = $oldStmts;
153-
154-
/** @var array<\PhpParser\Node\Stmt> $newStmts */
155155
$newStmts = $traverser1->traverse($nodesToTraverse);
156156

157157
$traverser2 = new NodeTraverser();
158158
$traverser2->addVisitor(new ContractVisitor());
159-
160-
/** @var array<\PhpParser\Node\Stmt> $newStmts */
161159
$newStmts = $traverser2->traverse($newStmts);
162160

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

187-
$transformed = str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed);
188-
189-
return $transformed;
185+
return str_replace(['/*__TYPEPHP_INJECTED_START__*/', '/*__TYPEPHP_INJECTED_END__*/'], '', $transformed);
190186
}
191187

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

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

0 commit comments

Comments
 (0)