Skip to content

Commit e1599a4

Browse files
authored
Improve vendor isolation (#37)
* Add TDD tests for vendor path isolation and whitelisting in FileFilter * Add test for differentiating application folders from vendor folder names in FileFilter * Enhance vendor path handling in FileFilter and StreamWrapper to support explicit vendor whitelisting and improve test isolation with reset methods in Config and StreamWrapper. * Improve code styling * Add more edge cases test in vendor isolation tests suite
1 parent bb9f4c1 commit e1599a4

16 files changed

Lines changed: 378 additions & 52 deletions

src/Contract/ContractParser.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -711,7 +711,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
711711

712712
if ($node instanceof CallableTypeNode) {
713713
$parameters = array_map(
714-
fn(CallableTypeParameterNode $param) => new CallableTypeParameterNode(
714+
fn (CallableTypeParameterNode $param) => new CallableTypeParameterNode(
715715
self::substituteAliases($param->type, $aliases),
716716
$param->isReference,
717717
$param->isVariadic,
@@ -745,7 +745,7 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
745745
if ($node instanceof GenericTypeNode) {
746746
$genericType = self::substituteAliases($node->type, $aliases);
747747
$genericTypes = array_map(
748-
fn($t) => self::substituteAliases($t, $aliases),
748+
fn ($t) => self::substituteAliases($t, $aliases),
749749
$node->genericTypes
750750
);
751751

@@ -762,14 +762,14 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
762762

763763
if ($node instanceof UnionTypeNode) {
764764
return new UnionTypeNode(array_map(
765-
fn($t) => self::substituteAliases($t, $aliases),
765+
fn ($t) => self::substituteAliases($t, $aliases),
766766
$node->types
767767
));
768768
}
769769

770770
if ($node instanceof IntersectionTypeNode) {
771771
return new IntersectionTypeNode(array_map(
772-
fn($t) => self::substituteAliases($t, $aliases),
772+
fn ($t) => self::substituteAliases($t, $aliases),
773773
$node->types
774774
));
775775
}

src/Contract/DocblockExtractor.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -424,4 +424,4 @@ public static function extractMagicMethodContract(string $doc, string $methodNam
424424

425425
return null;
426426
}
427-
}
427+
}

src/Contract/FileFilter.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,16 +82,31 @@ public static function isFileExcluded(string|false|null $fileName): bool
8282
self::compilePatterns();
8383
}
8484

85-
$longestIncludeMatch = 0;
8685
$isVendorPath = str_contains($normalizedPath, '/vendor/');
86+
if ($isVendorPath) {
87+
$hasExplicitVendorWhitelist = false;
88+
/** @var array<int, array{pattern: string, len: int, regex: string}> $includes */
89+
$includes = self::$compiledIncludes;
90+
foreach ($includes as $compiled) {
91+
if (str_starts_with($compiled['pattern'], 'vendor/') && preg_match($compiled['regex'], $normalizedPath) === 1) {
92+
$hasExplicitVendorWhitelist = true;
93+
94+
break;
95+
}
96+
}
97+
98+
if (! $hasExplicitVendorWhitelist) {
99+
return self::$pathFilterCache[$normalizedPath] = true; // Instantly exclude!
100+
}
101+
}
87102

103+
$longestIncludeMatch = 0;
88104
/** @var array<int, array{pattern: string, len: int, regex: string}> $includes */
89105
$includes = self::$compiledIncludes;
90106
foreach ($includes as $compiled) {
91107
$isExplicitVendorInclude = str_starts_with($compiled['pattern'], 'vendor/');
92108
$isWildcard = ($compiled['pattern'] === '*' || $compiled['pattern'] === '**');
93109

94-
// Application include rules (like src/**, src/Core/**) never match inside vendor directories
95110
if ($isVendorPath && ! $isExplicitVendorInclude && ! $isWildcard) {
96111
continue;
97112
}
@@ -173,4 +188,4 @@ private static function compileGlobToRegex(string $glob, string $baseDir): strin
173188

174189
return '#' . $pattern . '#i';
175190
}
176-
}
191+
}

src/Internal/Config.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ final class Config
2323
*/
2424
private static ?array $cachedConfig = null;
2525

26+
/**
27+
* Cached absolute project root path.
28+
*/
2629
private static ?string $projectRoot = null;
2730

2831
private static bool $enabled = true;
@@ -208,6 +211,7 @@ public static function set(array $config): void
208211

209212
ContractParser::reset();
210213
FileFilter::reset();
214+
StreamWrapper::reset();
211215
}
212216

213217
/**
@@ -228,6 +232,7 @@ public static function reset(): void
228232
TemplateManager::reset();
229233
HierarchyResolver::reset();
230234
FileFilter::reset();
235+
StreamWrapper::reset();
231236
}
232237

233238
/**
@@ -244,4 +249,4 @@ private static function syncFlags(array $config): void
244249
self::$magicMethods = (bool) ($config['magic_methods'] ?? true);
245250
self::$respectIgnoreTags = (bool) ($config['respect_ignore_tags'] ?? true);
246251
}
247-
}
252+
}

src/Internal/StreamWrapper.php

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ final class StreamWrapper implements StreamWrapperInterface
5353

5454
private static string $cacheDir = '';
5555

56+
/**
57+
* Resets the compiled pattern cache. Useful for test isolation.
58+
*/
59+
public static function reset(): void
60+
{
61+
self::$isInitialized = false;
62+
self::$includeRawPatterns = [];
63+
self::$excludeRawPatterns = [];
64+
self::$baseDir = '';
65+
}
66+
5667
/**
5768
* @param array<string, mixed> $config
5869
*/
@@ -515,20 +526,32 @@ private static function isApplicationFile(string $path, string|false $resolvedPa
515526
return false;
516527
}
517528

518-
// Unconditionally prevent double-parsing cached files!
519529
$normalizedCacheDir = rtrim(str_replace('\\', '/', self::$cacheDir), '/') . '/';
520530
if (str_starts_with($normalizedPath, $normalizedCacheDir)) {
521531
return false;
522532
}
523533

524-
$longestIncludeMatch = 0;
525534
$isVendorPath = str_contains($normalizedPath, '/vendor/');
535+
if ($isVendorPath) {
536+
$hasExplicitVendorWhitelist = false;
537+
foreach (self::$includeRawPatterns as $pattern => $regex) {
538+
if (str_starts_with($pattern, 'vendor/') && preg_match($regex, $normalizedPath) === 1) {
539+
$hasExplicitVendorWhitelist = true;
526540

541+
break;
542+
}
543+
}
544+
545+
if (! $hasExplicitVendorWhitelist) {
546+
return false;
547+
}
548+
}
549+
550+
$longestIncludeMatch = 0;
527551
foreach (self::$includeRawPatterns as $pattern => $regex) {
528552
$isExplicitVendorInclude = str_starts_with($pattern, 'vendor/');
529553
$isWildcard = ($pattern === '*' || $pattern === '**');
530554

531-
// Application include rules (like src/**, src/Core/**) never match inside vendor directories
532555
if ($isVendorPath && ! $isExplicitVendorInclude && ! $isWildcard) {
533556
continue;
534557
}
@@ -661,4 +684,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat
661684

662685
SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs);
663686
}
664-
}
687+
}

src/Resolver/SpecialTypeResolver.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1061,4 +1061,4 @@ private static function parseFileMetadata(string $fileName, string $source): voi
10611061
// Silently fall back to empty metadata if parsing fails
10621062
}
10631063
}
1064-
}
1064+
}

src/Validator/TypeValidatorRegistry.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -89,4 +89,4 @@ public function validate(mixed $value, TypeNode $node, string $context): ?ErrorM
8989

9090
return $err;
9191
}
92-
}
92+
}

0 commit comments

Comments
 (0)