diff --git a/CHANGELOG b/CHANGELOG index 39b5278dd..202697ce0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,7 @@ * Fix `IntlExtension` letting the pattern derived from a date formatter prototype override an explicit locale * Fix `IntlExtension` not honoring the locale of a date formatter prototype configured with no date and time styles * Speed up macro calls + * Add `TemplateWrapper::getDefaultEscapeStrategy()` to know the escaping strategy a template was compiled with * Report a clear error when `random`, `reverse`, `shuffle`, and `split` receive a string that is not valid UTF-8 * Fix the deprecation about omitting parentheses when calling a macro being reported twice for the same call * Add the macro name to the deprecation about omitting parentheses when calling a macro diff --git a/doc/api.rst b/doc/api.rst index a58afb2b7..18bec5ca1 100644 --- a/doc/api.rst +++ b/doc/api.rst @@ -48,6 +48,22 @@ returns a ``\Twig\TemplateWrapper`` instance:: $template = $twig->load('index.html.twig'); +.. versionadded:: 3.30 + + The ``TemplateWrapper::getDefaultEscapeStrategy()`` method was introduced in + Twig 3.30. + +The wrapper tells which escaping strategy the body of the template was compiled +with (``false`` when the template is not autoescaped):: + + $strategy = $template->getDefaultEscapeStrategy(); + +.. caution:: + + This describes the template's own source, not its output: ``autoescape``, + ``escape``, and anything rendered by a parent, embedded, or included + template can use another strategy. + Rendering Templates ------------------- diff --git a/src/Node/ModuleNode.php b/src/Node/ModuleNode.php index 13eef99ad..5c27a10ea 100644 --- a/src/Node/ModuleNode.php +++ b/src/Node/ModuleNode.php @@ -73,6 +73,7 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface parent::__construct($nodes, [ 'index' => null, 'embedded_templates' => $embeddedTemplates, + 'strategy' => false, ], 1); // populate the template name of all node children @@ -121,6 +122,8 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface $this->compileIsTraitable($compiler); + $this->compileGetDefaultEscapeStrategy($compiler); + $this->compileDebugInfo($compiler); $this->compileGetSourceContext($compiler); @@ -462,6 +465,26 @@ final class ModuleNode extends Node implements CoercesChildrenToStringInterface ; } + protected function compileGetDefaultEscapeStrategy(Compiler $compiler): void + { + if (false === $strategy = $this->getAttribute('strategy')) { + return; + } + + $compiler + ->write("/**\n") + ->write(" * @codeCoverageIgnore\n") + ->write(" */\n") + ->write("public function getDefaultEscapeStrategy(): string|false\n", "{\n") + ->indent() + ->write('return ') + ->repr($strategy) + ->raw(";\n") + ->outdent() + ->write("}\n\n") + ; + } + protected function compileDebugInfo(Compiler $compiler): void { $compiler diff --git a/src/NodeVisitor/EscaperNodeVisitor.php b/src/NodeVisitor/EscaperNodeVisitor.php index a9f829770..954979a4a 100644 --- a/src/NodeVisitor/EscaperNodeVisitor.php +++ b/src/NodeVisitor/EscaperNodeVisitor.php @@ -52,6 +52,8 @@ final class EscaperNodeVisitor implements NodeVisitorInterface if ($env->hasExtension(EscaperExtension::class) && $defaultStrategy = $env->getExtension(EscaperExtension::class)->getDefaultStrategy($node->getTemplateName())) { $this->defaultStrategy = $defaultStrategy; } + // the compiled template exposes the strategy it was compiled with + $node->setAttribute('strategy', \is_string($this->defaultStrategy) ? $this->defaultStrategy : false); $this->safeVars = []; $this->blocks = []; } elseif ($node instanceof AutoEscapeNode) { diff --git a/src/Template.php b/src/Template.php index 5bbff8880..f24b2b768 100644 --- a/src/Template.php +++ b/src/Template.php @@ -67,6 +67,20 @@ abstract class Template */ abstract public function getSourceContext(): Source; + /** + * Returns the escaping strategy the template body was compiled with. + * + * This describes the template's own source, not its output: `autoescape`, + * `escape`, and anything rendered by a parent, embedded, or included + * template can use another strategy. + * + * @return string|false The strategy name or false when the template is not autoescaped + */ + public function getDefaultEscapeStrategy(): string|false + { + return false; + } + /** * Returns the parent template. * diff --git a/src/TemplateWrapper.php b/src/TemplateWrapper.php index ab1c414fd..c900d74fe 100644 --- a/src/TemplateWrapper.php +++ b/src/TemplateWrapper.php @@ -96,6 +96,20 @@ final class TemplateWrapper return $this->template->getTemplateName(); } + /** + * Returns the escaping strategy the template body was compiled with. + * + * This describes the template's own source, not its output: `autoescape`, + * `escape`, and anything rendered by a parent, embedded, or included + * template can use another strategy. + * + * @return string|false The strategy name or false when the template is not autoescaped + */ + public function getDefaultEscapeStrategy(): string|false + { + return $this->template->getDefaultEscapeStrategy(); + } + /** * @internal */ diff --git a/tests/TemplateEscapeStrategyTest.php b/tests/TemplateEscapeStrategyTest.php new file mode 100644 index 000000000..e8dd39125 --- /dev/null +++ b/tests/TemplateEscapeStrategyTest.php @@ -0,0 +1,124 @@ + '{{ value }}', + ]), ['autoescape' => $autoescape, 'cache' => false]); + + $this->assertSame($expected, $twig->load($name)->getDefaultEscapeStrategy()); + } + + public static function provideTemplates() + { + // compiled class names derive from the template name alone, so reusing a name across cases would silently reuse the first compiled class + return [ + ['html', 'guessed_html.html.twig', 'name'], + ['js', 'guessed_js.js.twig', 'name'], + [false, 'guessed_none.txt.twig', 'name'], + ['html', 'forced_html.js.twig', 'html'], + [false, 'disabled.html.twig', false], + ]; + } + + /** + * @dataProvider provideAutoescapeTags + */ + #[DataProvider('provideAutoescapeTags')] + public function testAnAutoescapeTagDoesNotChangeTheStrategyOfTheTemplate(string $name, string $tag): void + { + $twig = new Environment(new ArrayLoader([ + $name => "{% autoescape $tag %}{{ value }}{% endautoescape %}", + ]), ['autoescape' => 'name', 'cache' => false]); + + $this->assertSame('html', $twig->load($name)->getDefaultEscapeStrategy()); + } + + public static function provideAutoescapeTags() + { + return [ + ['another_strategy.html.twig', "'js'"], + ['no_escaping.html.twig', 'false'], + ]; + } + + public function testTheStrategyDescribesTheBodyAndNotWhatTheTemplateRenders(): void + { + $twig = new Environment(new ArrayLoader([ + 'inherits.html.twig' => "{% extends 'inherited.txt.twig' %}", + 'inherited.txt.twig' => '{{ value }}', + ]), ['autoescape' => 'name', 'cache' => false]); + + $template = $twig->load('inherits.html.twig'); + + $this->assertSame('html', $template->getDefaultEscapeStrategy()); + $this->assertSame('', $template->render(['value' => ''])); + } + + public function testEmbeddedTemplatesExposeTheStrategyOfTheirTemplate(): void + { + $twig = new Environment(new ArrayLoader([ + 'index.js.twig' => "{% embed 'layout.html.twig' %}{% block content %}{{ value }}{% endblock %}{% endembed %}", + 'layout.html.twig' => '{% block content %}{% endblock %}', + ]), ['autoescape' => 'name', 'cache' => false]); + + $compiled = $twig->compileSource(new Source($twig->getLoader()->getSourceContext('index.js.twig')->getCode(), 'index.js.twig')); + + $this->assertSame(2, substr_count($compiled, 'public function getDefaultEscapeStrategy(): string|false')); + $this->assertSame(2, substr_count($compiled, 'return "js";')); + } + + public function testTemplatesCompiledBeforeTheStrategyWasExposedReportNoStrategy(): void + { + $twig = new Environment(new ArrayLoader(['index.html.twig' => '{{ value }}']), ['autoescape' => 'name', 'cache' => false]); + + $this->assertFalse((new TemplateWithoutStrategy($twig))->getDefaultEscapeStrategy()); + } +} + +class TemplateWithoutStrategy extends Template +{ + public function getTemplateName(): string + { + return 'index.html.twig'; + } + + public function getDebugInfo(): array + { + return []; + } + + public function getSourceContext(): Source + { + return new Source('', $this->getTemplateName()); + } + + protected function doDisplay(array $context, array $blocks = []): iterable + { + yield ''; + } +} diff --git a/tests/TemplateWrapperTest.php b/tests/TemplateWrapperTest.php index 1807db61d..38cf7a51f 100644 --- a/tests/TemplateWrapperTest.php +++ b/tests/TemplateWrapperTest.php @@ -43,6 +43,13 @@ class TemplateWrapperTest extends TestCase $wrapper->unwrap(new Environment(new ArrayLoader())); } + public function testGetDefaultEscapeStrategy(): void + { + $twig = new Environment(new ArrayLoader(['index.js.twig' => 'content']), ['autoescape' => 'name']); + + $this->assertSame('js', $twig->load('index.js.twig')->getDefaultEscapeStrategy()); + } + public function testHasGetBlocks(): void { $twig = new Environment(new ArrayLoader([