Skip to content

Commit f75effe

Browse files
authored
[tainting] Fix the precedence of the CachedTemplatesMapping (#89)
Allow alternatives template name notation Isolate template naming in a CachedTemplatesRegistry Allow `render` calls with no second arguments Allow twig template name old notation alternatives
1 parent 01b5dcb commit f75effe

8 files changed

+329
-47
lines changed

src/Test/CodeceptionModule.php

Lines changed: 68 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
namespace Psalm\SymfonyPsalmPlugin\Test;
66

77
use Codeception\Module as BaseModule;
8+
use Codeception\TestInterface;
89
use InvalidArgumentException;
10+
use Psalm\SymfonyPsalmPlugin\Twig\CachedTemplatesMapping;
911
use Twig\Cache\FilesystemCache;
1012
use Twig\Environment;
1113
use Twig\Extension\AbstractExtension;
@@ -24,21 +26,39 @@ class CodeceptionModule extends BaseModule
2426

2527
public const TWIG_TEMPLATES_DIR = 'templates';
2628

29+
/**
30+
* @var FilesystemCache|null
31+
*/
32+
private $twigCache;
33+
34+
/**
35+
* @var string|null
36+
*/
37+
private $lastCachePath;
38+
39+
public function _after(TestInterface $test): void
40+
{
41+
$this->twigCache = $this->lastCachePath = null;
42+
}
43+
2744
/**
2845
* @Given I have the following :templateName template :code
2946
*/
3047
public function haveTheFollowingTemplate(string $templateName, string $code): void
3148
{
3249
$rootDirectory = rtrim($this->config['default_dir'], DIRECTORY_SEPARATOR);
33-
$templateRootDirectory = $rootDirectory.DIRECTORY_SEPARATOR.self::TWIG_TEMPLATES_DIR;
34-
if (!file_exists($templateRootDirectory)) {
35-
mkdir($templateRootDirectory);
50+
$templatePath = (
51+
$rootDirectory.DIRECTORY_SEPARATOR.
52+
self::TWIG_TEMPLATES_DIR.DIRECTORY_SEPARATOR.
53+
$templateName
54+
);
55+
56+
$templateDirectory = dirname($templatePath);
57+
if (!file_exists($templateDirectory)) {
58+
mkdir($templateDirectory, 0755, true);
3659
}
3760

38-
file_put_contents(
39-
$templateRootDirectory.DIRECTORY_SEPARATOR.$templateName,
40-
$code
41-
);
61+
file_put_contents($templatePath, $code);
4262
}
4363

4464
/**
@@ -52,25 +72,57 @@ public function haveTheTemplateCompiled(string $templateName, string $cacheDirec
5272
mkdir($cacheDirectory, 0755, true);
5373
}
5474

55-
$twigEnvironment = self::getEnvironment($rootDirectory, $cacheDirectory);
56-
$twigEnvironment->load($templateName);
75+
$this->loadTemplate($templateName, $rootDirectory, $cacheDirectory);
76+
}
77+
78+
/**
79+
* @Given the last compiled template got his alias changed to :newAlias
80+
*/
81+
public function changeTheLastTemplateAlias(string $newAlias): void
82+
{
83+
if (null === $this->lastCachePath) {
84+
throw new \RuntimeException('You have to compile a template first.');
85+
}
86+
87+
$cacheContent = file_get_contents($this->lastCachePath);
88+
89+
if (!preg_match('/'.CachedTemplatesMapping::CACHED_TEMPLATE_HEADER_PATTERN.'/m', $cacheContent, $cacheHeadParts)) {
90+
throw new \RuntimeException('The cache file is somehow malformed.');
91+
}
92+
93+
file_put_contents($this->lastCachePath, str_replace(
94+
$cacheHeadParts[0],
95+
str_replace($cacheHeadParts['name'], $newAlias, $cacheHeadParts[0]),
96+
$cacheContent
97+
));
5798
}
5899

59-
private static function getEnvironment(string $rootDirectory, string $cacheDirectory): Environment
100+
private function loadTemplate(string $templateName, string $rootDirectory, string $cacheDirectory): void
101+
{
102+
if (null === $this->twigCache) {
103+
if (!is_dir($cacheDirectory)) {
104+
throw new InvalidArgumentException(sprintf('The %s twig cache directory does not exist or is not readable.', $cacheDirectory));
105+
}
106+
$this->twigCache = new FilesystemCache($cacheDirectory);
107+
}
108+
109+
$twigEnvironment = self::getEnvironment($rootDirectory, $this->twigCache);
110+
$template = $twigEnvironment->load($templateName);
111+
112+
/** @psalm-suppress InternalMethod */
113+
$this->lastCachePath = $this->twigCache->generateKey($templateName, get_class($template->unwrap()));
114+
}
115+
116+
private static function getEnvironment(string $rootDirectory, FilesystemCache $twigCache): Environment
60117
{
61118
if (!file_exists($rootDirectory.DIRECTORY_SEPARATOR.self::TWIG_TEMPLATES_DIR)) {
62119
mkdir($rootDirectory.DIRECTORY_SEPARATOR.self::TWIG_TEMPLATES_DIR);
63120
}
64121

65122
$loader = new FilesystemLoader(self::TWIG_TEMPLATES_DIR, $rootDirectory);
66123

67-
if (!is_dir($cacheDirectory)) {
68-
throw new InvalidArgumentException(sprintf('The %s twig cache directory does not exist or is not readable.', $cacheDirectory));
69-
}
70-
$cache = new FilesystemCache($cacheDirectory);
71-
72124
$twigEnvironment = new Environment($loader, [
73-
'cache' => $cache,
125+
'cache' => $twigCache,
74126
'auto_reload' => true,
75127
'debug' => true,
76128
'optimizations' => 0,
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psalm\SymfonyPsalmPlugin\Twig;
6+
7+
use Exception;
8+
9+
class CachedTemplateNotFoundException extends Exception
10+
{
11+
public function __construct()
12+
{
13+
parent::__construct('No cache found for template with name(s) :');
14+
}
15+
16+
public function addTriedName(string $possibleName): void
17+
{
18+
$this->message .= ' '.$possibleName;
19+
}
20+
}

src/Twig/CachedTemplatesMapping.php

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,68 +5,68 @@
55
namespace Psalm\SymfonyPsalmPlugin\Twig;
66

77
use Psalm\Codebase;
8-
use Psalm\Context;
9-
use Psalm\Plugin\Hook\AfterFileAnalysisInterface;
10-
use Psalm\StatementsSource;
11-
use Psalm\Storage\FileStorage;
8+
use Psalm\Plugin\Hook\AfterCodebasePopulatedInterface;
129
use RuntimeException;
1310

1411
/**
1512
* This class is used to store a mapping of all analyzed twig template cache files with their corresponding actual templates.
1613
*/
17-
class CachedTemplatesMapping implements AfterFileAnalysisInterface
14+
class CachedTemplatesMapping implements AfterCodebasePopulatedInterface
1815
{
1916
/**
2017
* @var string
2118
*/
22-
private const CACHED_TEMPLATE_HEADER_PATTERN = 'use Twig\\\\Template;\n\n\/\* (@?.+\.twig) \*\/\nclass __TwigTemplate';
19+
public const CACHED_TEMPLATE_HEADER_PATTERN =
20+
'use Twig\\\\Template;\n\n'.
21+
'\/\* (?<name>@?.+\.twig) \*\/\n'.
22+
'class (?<class>__TwigTemplate_[a-z0-9]{64}) extends (\\\\Twig\\\\)?Template';
2323

2424
/**
2525
* @var string|null
2626
*/
27-
public static $cache_path;
27+
private static $cachePath;
2828

2929
/**
30-
* @var array<string, string>
30+
* @var CachedTemplatesRegistry|null
3131
*/
32-
private static $mapping = [];
32+
private static $cacheRegistry;
3333

34-
public static function afterAnalyzeFile(StatementsSource $statements_source, Context $file_context, FileStorage $file_storage, Codebase $codebase): void
34+
public static function afterCodebasePopulated(Codebase $codebase)
3535
{
36-
if (null === self::$cache_path || 0 !== strpos($file_storage->file_path, self::$cache_path)) {
36+
if (null === self::$cachePath) {
3737
return;
3838
}
3939

40-
$rawSource = file_get_contents($file_storage->file_path);
41-
if (!preg_match('/'.self::CACHED_TEMPLATE_HEADER_PATTERN.'/m', $rawSource, $matchingParts)) {
42-
return;
43-
}
40+
self::$cacheRegistry = new CachedTemplatesRegistry();
41+
$cacheFiles = $codebase->file_provider->getFilesInDir(self::$cachePath, ['php']);
4442

45-
/** @var string|null $cacheClassName */
46-
[$cacheClassName] = array_values($file_storage->classlikes_in_file);
47-
if (null === $cacheClassName) {
48-
return;
49-
}
43+
foreach ($cacheFiles as $file) {
44+
$rawSource = $codebase->file_provider->getContents($file);
5045

51-
self::registerNewCache($cacheClassName, $matchingParts[1]);
52-
}
46+
if (!preg_match('/'.self::CACHED_TEMPLATE_HEADER_PATTERN.'/m', $rawSource, $matchingParts)) {
47+
continue;
48+
}
49+
$templateName = $matchingParts['name'];
50+
$cacheClassName = $matchingParts['class'];
5351

54-
public static function setCachePath(string $cache_path): void
55-
{
56-
static::$cache_path = $cache_path;
52+
self::$cacheRegistry->addTemplate($cacheClassName, $templateName);
53+
}
5754
}
5855

59-
private static function registerNewCache(string $cacheClassName, string $templateName): void
56+
public static function setCachePath(string $cachePath): void
6057
{
61-
static::$mapping[$templateName] = $cacheClassName;
58+
self::$cachePath = $cachePath;
6259
}
6360

61+
/**
62+
* @throws CachedTemplateNotFoundException
63+
*/
6464
public static function getCacheClassName(string $templateName): string
6565
{
66-
if (!array_key_exists($templateName, static::$mapping)) {
67-
throw new RuntimeException(sprintf('The template %s was not found.', $templateName));
66+
if (null === self::$cacheRegistry) {
67+
throw new RuntimeException(sprintf('Can not load template %s, because no cache registry is provided.', $templateName));
6868
}
6969

70-
return static::$mapping[$templateName];
70+
return self::$cacheRegistry->getCacheClassName($templateName);
7171
}
7272
}

src/Twig/CachedTemplatesRegistry.php

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Psalm\SymfonyPsalmPlugin\Twig;
6+
7+
use Generator;
8+
9+
class CachedTemplatesRegistry
10+
{
11+
/**
12+
* @var array<string, string>
13+
*/
14+
private $mapping = [];
15+
16+
public function addTemplate(string $cacheClassName, string $templateName): void
17+
{
18+
$this->mapping[$templateName] = $cacheClassName;
19+
}
20+
21+
/**
22+
* @throws CachedTemplateNotFoundException
23+
*/
24+
public function getCacheClassName(string $templateName): string
25+
{
26+
$probableException = new CachedTemplateNotFoundException();
27+
28+
foreach (self::generateNames($templateName) as $possibleName) {
29+
if (array_key_exists($possibleName, $this->mapping)) {
30+
return $this->mapping[$possibleName];
31+
}
32+
$probableException->addTriedName($possibleName);
33+
}
34+
35+
throw $probableException;
36+
}
37+
38+
/**
39+
* @return Generator<string>
40+
*/
41+
private static function generateNames(string $baseName): Generator
42+
{
43+
yield $baseName;
44+
45+
/** @var string|null $oldNotation */
46+
$oldNotation = null;
47+
48+
$alternativeNotation = preg_replace('/^@([^\/]+)\/?(.+)?\/([^\/]+\.twig)/', '$1Bundle:$2:$3', $baseName);
49+
if ($alternativeNotation !== $baseName) {
50+
yield $alternativeNotation;
51+
$oldNotation = $alternativeNotation;
52+
}
53+
54+
$alternativeNotation = preg_replace('/^(.+)Bundle:(.+)?:(.+\.twig)$/', '@$1/$2/$3', $baseName);
55+
if ($alternativeNotation !== $baseName) {
56+
yield str_replace('//', '/', $alternativeNotation);
57+
$oldNotation = $baseName;
58+
}
59+
60+
if (null !== $oldNotation) {
61+
list($bundleName, $rest) = explode(':', $oldNotation, 2);
62+
list($revTemplateName, $revRest) = explode(':', strrev($rest), 2);
63+
$pathParts = explode('/', strrev($revRest));
64+
$pathParts = array_merge($pathParts, explode('/', strrev($revTemplateName)));
65+
for ($i = 0; $i <= count($pathParts); ++$i) {
66+
yield $bundleName.':'.
67+
implode('/', array_slice($pathParts, 0, $i)).':'.
68+
implode('/', array_slice($pathParts, $i));
69+
}
70+
}
71+
}
72+
}

src/Twig/CachedTemplatesTainter.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ public static function getMethodReturnType(
5656
new Identifier(
5757
'doDisplay'
5858
),
59-
[$call_args[1]]
59+
isset($call_args[1]) ? [$call_args[1]] : []
6060
);
6161

6262
$firstArgument = $call_args[0]->value;

tests/acceptance/acceptance/TwigTaintingWithAnalyzer.feature

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ Feature: Twig tainting with analyzer
3636
function twig() {}
3737
"""
3838

39+
Scenario: The twig rendering has no parameters
40+
Given I have the following code
41+
"""
42+
twig()->render('index.html.twig');
43+
"""
44+
And I have the following "index.html.twig" template
45+
"""
46+
<h1>
47+
Nothing.
48+
</h1>
49+
"""
50+
When I run Psalm with taint analysis
51+
And I see no errors
52+
3953
Scenario: One parameter of the twig rendering is tainted but autoescaping is on
4054
Given I have the following code
4155
"""

0 commit comments

Comments
 (0)