Merge remote-tracking branch 'origin/3.x' into 4.x

* origin/3.x:
  Add a `needs_is_sandboxed` option for filters, functions, and tests
  Bump version
  Make embeds deterministic
  [Doc] Document loose comparison in the `in` operator
  [Doc] Reword whitespace control note about first-newline removal
  Stop publishing extra package minor versions with no changes
  Lazy load EscaperRuntime in EscaperExtension
  Fix typo
  Replace parent-child analogy in `doc/tags/extends.rst`
  doc: Add missing toctree entries and fix ordering
  Fix CHANGELOG
  Bump version
  Prepare the 3.24.0 release
This commit is contained in:
Fabien Potencier
2026-05-17 08:18:04 +01:00
22 changed files with 211 additions and 9 deletions
+28
View File
@@ -216,6 +216,34 @@ the first argument to the filter call (or the second one if
// ...
}, ['needs_context' => true, 'needs_environment' => true]);
Sandbox-aware Filters
~~~~~~~~~~~~~~~~~~~~~
.. versionadded:: 3.25
The ``needs_is_sandboxed`` option was added in Twig 3.25.
If you want to know whether the current template is sandboxed in your
filter, set the ``needs_is_sandboxed`` option to ``true``; Twig will pass the
current sandbox state as a boolean to the filter call (as the first
argument, or after the charset, the environment, and the context if they
are also requested)::
$filter = new \Twig\TwigFilter('rot13', function (bool $isSandboxed, $string) {
if ($isSandboxed) {
// adjust behavior when running in a sandboxed template
}
return str_rot13($string);
}, ['needs_is_sandboxed' => true]);
The sandbox state is resolved against the current template source, which
means it takes both the global sandbox state and any ``SourcePolicy`` into
account.
The same ``needs_is_sandboxed`` option is also available on functions and
tests.
Automatic Escaping
~~~~~~~~~~~~~~~~~~
+1 -1
View File
@@ -3,7 +3,7 @@
.. _html_attr:
.. versionadded:: 3.23
.. versionadded:: 3.24
The ``html_attr`` function was added in Twig 3.24.
+1
View File
@@ -14,6 +14,7 @@ Twig
deprecated
recipes
coding_standards
operators_precedence
tags/index
filters/index
functions/index
+2 -2
View File
@@ -71,8 +71,8 @@ value from the parent template is used instead.
You can't define multiple ``block`` tags with the same name in the same
template. This limitation exists because a block tag works in "both"
directions. That is, a block tag doesn't just provide a hole to fill - it also
defines the content that fills the hole in the *parent*. If there were two
directions. That is, a block tag doesn't just provide a slot to fill - it also
defines the content that fills the slot in the *parent*. If there were two
similarly-named ``block`` tags in a template, that template's parent wouldn't
know which one of the blocks' content to use.
+1 -1
View File
@@ -12,10 +12,10 @@ Tags
do
embed
extends
guard
flush
for
from
guard
if
import
include
+16 -1
View File
@@ -780,6 +780,21 @@ operand is contained in the right:
You can use this operator to perform a containment test on strings,
sequences, mappings, or objects implementing the ``Traversable`` interface.
.. note::
For sequences, mappings, and ``Traversable`` objects, ``in`` uses a loose
comparison (similar to ``==``); use :doc:`same as <tests/sameas>` for a
strict comparison. Like PHP's ``in_array()``, this can yield unexpected
results when the left operand is a boolean:
.. code-block:: twig
{# returns true because true == 'foo' under PHP loose comparison #}
{{ true in ['foo', 'bar'] }}
Containment on strings only accepts string, integer, and float operands on
the left; other types always return ``false``.
To perform a negative test, use the ``not in`` operator:
.. code-block:: twig
@@ -1181,7 +1196,7 @@ Twig supports two modifiers:
* *Line whitespace trimming* via the ``~`` modifier: Removes all whitespace
(excluding newlines). Using this modifier on the right disables the default
removal of the first newline inherited from PHP.
removal of the first newline mentioned above.
The modifiers can be used on either side of the tags like in ``{%-`` or ``-%}``
and they consume all whitespace for that side of the tag. It is possible to use
+2
View File
@@ -10,6 +10,8 @@ Tests
empty
even
iterable
mapping
null
odd
sameas
sequence
+2 -1
View File
@@ -10,6 +10,7 @@
"string-extra": "extra/string-extra"
},
"defaults": {
"git_constraint": "<1.8.2"
"git_constraint": "<1.8.2",
"tag_skip": "minor"
}
}
+7 -1
View File
@@ -38,6 +38,7 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
'needs_environment' => false,
'needs_context' => false,
'needs_charset' => false,
'needs_is_sandboxed' => false,
'is_variadic' => false,
'deprecation_info' => null,
], $options);
@@ -95,6 +96,11 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
return $this->options['needs_context'];
}
public function needsIsSandboxed(): bool
{
return $this->options['needs_is_sandboxed'];
}
/**
* @return static
*/
@@ -145,6 +151,6 @@ abstract class AbstractTwigCallable implements TwigCallableInterface
public function getMinimalNumberOfRequiredArguments(): int
{
return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + \count($this->arguments);
return ($this->options['needs_charset'] ? 1 : 0) + ($this->options['needs_environment'] ? 1 : 0) + ($this->options['needs_context'] ? 1 : 0) + ($this->options['needs_is_sandboxed'] ? 1 : 0) + \count($this->arguments);
}
}
+2
View File
@@ -35,6 +35,7 @@ final class AsTwigFilter
* @param bool|null $needsCharset Whether the filter needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the filter needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the filter needs the context array passed as the first argument, or after the charset and the environment
* @param bool|null $needsIsSandboxed Whether the filter needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the filter is safe
* @param string|null $preEscape Some filters may need to work on input that is already escaped or safe
@@ -46,6 +47,7 @@ final class AsTwigFilter
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?array $isSafe = null,
public string|array|null $isSafeCallback = null,
public ?string $preEscape = null,
+2
View File
@@ -35,6 +35,7 @@ final class AsTwigFunction
* @param bool|null $needsCharset Whether the function needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the function needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the function needs the context array passed as the first argument, or after the charset and the environment
* @param bool|null $needsIsSandboxed Whether the function needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context
* @param string[]|null $isSafe List of formats in which you want the raw output to be printed unescaped
* @param string|array|null $isSafeCallback Function called at compilation time to determine if the function is safe
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
@@ -44,6 +45,7 @@ final class AsTwigFunction
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?array $isSafe = null,
public string|array|null $isSafeCallback = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
+2
View File
@@ -35,6 +35,7 @@ final class AsTwigTest
* @param bool|null $needsCharset Whether the test needs the charset passed as the first argument
* @param bool|null $needsEnvironment Whether the test needs the environment passed as the first argument, or after the charset
* @param bool|null $needsContext Whether the test needs the context array passed as the first argument, or after the charset and the environment
* @param bool|null $needsIsSandboxed Whether the test needs the current sandbox state (a boolean) passed as the first argument, or after the charset, the environment, and the context
* @param DeprecatedCallableInfo|null $deprecationInfo Information about the deprecation
*/
public function __construct(
@@ -42,6 +43,7 @@ final class AsTwigTest
public ?bool $needsCharset = null,
public ?bool $needsEnvironment = null,
public ?bool $needsContext = null,
public ?bool $needsIsSandboxed = null,
public ?DeprecatedCallableInfo $deprecationInfo = null,
) {
}
+3
View File
@@ -95,6 +95,7 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'is_safe' => $attribute->isSafe,
'is_safe_callback' => $attribute->isSafeCallback,
@@ -118,6 +119,7 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'is_safe' => $attribute->isSafe,
'is_safe_callback' => $attribute->isSafeCallback,
@@ -139,6 +141,7 @@ final class AttributeExtension extends AbstractExtension
'needs_context' => $attribute->needsContext ?? false,
'needs_environment' => $attribute->needsEnvironment ?? $this->needsEnvironment($method),
'needs_charset' => $attribute->needsCharset ?? false,
'needs_is_sandboxed' => $attribute->needsIsSandboxed ?? false,
'is_variadic' => $method->isVariadic(),
'deprecation_info' => $attribute->deprecationInfo,
]);
+8
View File
@@ -93,6 +93,14 @@ abstract class CallExpression extends AbstractExpression
$first = false;
}
if ($twigCallable->needsIsSandboxed()) {
if (!$first) {
$compiler->raw(', ');
}
$compiler->raw('$this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source)');
$first = false;
}
foreach ($twigCallable->getArguments() as $argument) {
if (!$first) {
$compiler->raw(', ');
+8 -2
View File
@@ -71,6 +71,7 @@ class Parser
* @var ModuleNode[]
*/
private array $embeddedTemplates = [];
private int $lastEmbedIndex = 0;
private int $varNameSalt = 0;
private bool $ignoreUnknownTwigCallables = false;
private ExpressionParsers $parsers;
@@ -91,8 +92,13 @@ class Parser
*/
public function parse(TokenStream $stream, $test = null, bool $dropNeedle = false): ModuleNode
{
// reset on root parse() calls only, so the counter spans nested/reentrant parses
if (!$this->stack) {
$this->lastEmbedIndex = 0;
}
$vars = get_object_vars($this);
unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['reservedMacroNames'], $vars['varNameSalt']);
unset($vars['stack'], $vars['env'], $vars['handlers'], $vars['visitors'], $vars['reservedMacroNames'], $vars['lastEmbedIndex'], $vars['varNameSalt']);
$this->stack[] = $vars;
// node visitors
@@ -293,7 +299,7 @@ class Parser
*/
public function embedTemplate(ModuleNode $template)
{
$template->setIndex(mt_rand());
$template->setIndex(++$this->lastEmbedIndex);
$this->embeddedTemplates[] = $template;
}
+2
View File
@@ -35,6 +35,8 @@ interface TwigCallableInterface extends \Stringable
public function needsContext(): bool;
public function needsIsSandboxed(): bool;
public function withDynamicArguments(string $name, string $dynamicName, array $arguments): self;
public function getArguments(): array;
+4
View File
@@ -14,6 +14,7 @@ namespace Twig\Util;
use Twig\Error\SyntaxError;
use Twig\Node\Expression\AbstractExpression;
use Twig\Node\Expression\ArrayExpression;
use Twig\Node\Expression\CallExpression;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Expression\VariadicExpression;
use Twig\Node\Node;
@@ -194,6 +195,9 @@ final class CallableArgumentsExtractor
if ($this->twigCallable->needsContext()) {
array_shift($parameters);
}
if ($this->twigCallable->needsIsSandboxed()) {
array_shift($parameters);
}
foreach ($this->twigCallable->getArguments() as $argument) {
array_shift($parameters);
}
@@ -48,6 +48,7 @@ class AttributeExtensionTest extends TestCase
yield 'with env' => ['with_env_filter', 'withEnvFilter', ['needs_environment' => true]];
yield 'with context' => ['with_context_filter', 'withContextFilter', ['needs_context' => true]];
yield 'with env and context' => ['with_env_and_context_filter', 'withEnvAndContextFilter', ['needs_environment' => true, 'needs_context' => true]];
yield 'with sandbox' => ['with_sandbox_filter', 'withSandboxFilter', ['needs_is_sandboxed' => true]];
yield 'variadic' => ['variadic_filter', 'variadicFilter', ['is_variadic' => true]];
yield 'deprecated' => ['deprecated_filter', 'deprecatedFilter', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
yield 'pattern' => ['pattern_*_filter', 'patternFilter', []];
@@ -76,6 +77,7 @@ class AttributeExtensionTest extends TestCase
yield 'with env' => ['with_env_function', 'withEnvFunction', ['needs_environment' => true]];
yield 'with context' => ['with_context_function', 'withContextFunction', ['needs_context' => true]];
yield 'with env and context' => ['with_env_and_context_function', 'withEnvAndContextFunction', ['needs_environment' => true, 'needs_context' => true]];
yield 'with sandbox' => ['with_sandbox_function', 'withSandboxFunction', ['needs_is_sandboxed' => true]];
yield 'no argument' => ['no_arg_function', 'noArgFunction', []];
yield 'variadic' => ['variadic_function', 'variadicFunction', ['is_variadic' => true]];
yield 'deprecated' => ['deprecated_function', 'deprecatedFunction', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
@@ -104,6 +106,7 @@ class AttributeExtensionTest extends TestCase
yield 'with env' => ['with_env_test', 'withEnvTest', ['needs_environment' => true]];
yield 'with context' => ['with_context_test', 'withContextTest', ['needs_context' => true]];
yield 'with env and context' => ['with_env_and_context_test', 'withEnvAndContextTest', ['needs_environment' => true, 'needs_context' => true]];
yield 'with sandbox' => ['with_sandbox_test', 'withSandboxTest', ['needs_is_sandboxed' => true]];
yield 'variadic' => ['variadic_test', 'variadicTest', ['is_variadic' => true]];
yield 'deprecated' => ['deprecated_test', 'deprecatedTest', ['deprecation_info' => new DeprecatedCallableInfo('foo/bar', '1.2')]];
}
@@ -39,6 +39,11 @@ class ExtensionWithAttributes
{
}
#[AsTwigFilter('with_sandbox_filter', needsIsSandboxed: true)]
public function withSandboxFilter(bool $isSandboxed, string $string)
{
}
#[AsTwigFilter('variadic_filter')]
public function variadicFilter(string ...$strings)
{
@@ -74,6 +79,11 @@ class ExtensionWithAttributes
{
}
#[AsTwigFunction('with_sandbox_function', needsIsSandboxed: true)]
public function withSandboxFunction(bool $isSandboxed, string $string)
{
}
#[AsTwigFunction('no_arg_function')]
public function noArgFunction()
{
@@ -114,6 +124,11 @@ class ExtensionWithAttributes
{
}
#[AsTwigTest('with_sandbox_test', needsIsSandboxed: true)]
public function withSandboxTest(bool $isSandboxed, $argument)
{
}
#[AsTwigTest('deprecated_test', deprecationInfo: new DeprecatedCallableInfo('foo/bar', '1.2'))]
public function deprecatedTest($value, $argument)
{
+70
View File
@@ -548,6 +548,76 @@ EOF
$this->expectException(SecurityError::class);
$twig->load('1_basic')->render([]);
}
public function testNeedsIsSandboxedFilterReceivesTrueWhenSandboxed()
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "foo"|sandbox_aware }}'], [], ['sandbox_aware']);
$twig->addFilter(new \Twig\TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:on', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedFilterReceivesFalseWhenNotSandboxed()
{
$twig = $this->getEnvironment(false, [], ['index' => '{{ "foo"|sandbox_aware }}']);
$twig->addFilter(new \Twig\TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:off', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedFilterFollowsSourcePolicy()
{
$twig = $this->getEnvironment(false, [], [
'in' => '{{ "foo"|sandbox_aware }}',
'out' => '{{ "foo"|sandbox_aware }}',
], [], ['sandbox_aware'], [], [], [], new class implements \Twig\Sandbox\SourcePolicyInterface {
public function enableSandbox(Source $source): bool
{
return 'in' === $source->getName();
}
});
$twig->addFilter(new \Twig\TwigFilter('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:on', $twig->load('in')->render([]));
$this->assertSame('foo:off', $twig->load('out')->render([]));
}
public function testNeedsIsSandboxedFunctionWithoutSandboxExtension()
{
$loader = new ArrayLoader(['index' => '{{ sandbox_aware("foo") }}']);
$twig = new Environment($loader, ['debug' => true, 'cache' => false, 'autoescape' => false]);
$twig->addFunction(new \Twig\TwigFunction('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $value.':'.($isSandboxed ? 'on' : 'off');
}, ['needs_is_sandboxed' => true]));
$this->assertSame('foo:off', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedTestReceivesTrueWhenSandboxed()
{
$twig = $this->getEnvironment(true, [], ['index' => '{{ "foo" is sandbox_aware ? "on" : "off" }}']);
$twig->addTest(new \Twig\TwigTest('sandbox_aware', static function (bool $isSandboxed, string $value) {
return $isSandboxed && 'foo' === $value;
}, ['needs_is_sandboxed' => true]));
$this->assertSame('on', $twig->load('index')->render([]));
}
public function testNeedsIsSandboxedTestReceivesFalseWhenNotSandboxed()
{
$twig = $this->getEnvironment(false, [], ['index' => '{{ "foo" is sandbox_aware ? "on" : "off" }}']);
$twig->addTest(new \Twig\TwigTest('sandbox_aware', static function (bool $isSandboxed, string $value) {
return !$isSandboxed && 'foo' === $value;
}, ['needs_is_sandboxed' => true]));
$this->assertSame('on', $twig->load('index')->render([]));
}
}
class ParentClass
+18
View File
@@ -87,6 +87,14 @@ class FilterTest extends NodeTestCase
$node = self::createFilter($environment, new ConstantExpression('foo', 1), 'anonymous');
$tests[] = [$node, '$this->env->getFilter(\'anonymous\')->getCallable()("foo")'];
// needs sandbox
$node = self::createFilter($environment, $string, 'bar_sandbox');
$tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_sandbox($this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source), "abc")', $environment];
// needs charset, environment, context, and sandbox
$node = self::createFilter($environment, $string, 'bar_all');
$tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_all($this->env->getCharset(), $this->env, $context, $this->env->hasExtension(\Twig\Extension\SandboxExtension::class) && $this->env->getExtension(\Twig\Extension\SandboxExtension::class)->isSandboxed($this->source), "abc")', $environment];
// needs environment
$node = self::createFilter($environment, $string, 'bar');
$tests[] = [$node, 'Twig\Tests\Node\Expression\twig_tests_filter_dummy($this->env, "abc")', $environment];
@@ -176,6 +184,8 @@ class FilterTest extends NodeTestCase
$env->addFilter(new TwigFilter('bar', 'Twig\Tests\Node\Expression\twig_tests_filter_dummy', ['needs_environment' => true]));
$env->addFilter(new TwigFilter('bar_closure', \Closure::fromCallable(twig_tests_filter_dummy::class), ['needs_environment' => true]));
$env->addFilter(new TwigFilter('barbar', twig_tests_filter_barbar(...), ['needs_context' => true, 'is_variadic' => true]));
$env->addFilter(new TwigFilter('bar_sandbox', twig_tests_filter_sandbox(...), ['needs_is_sandboxed' => true]));
$env->addFilter(new TwigFilter('bar_all', twig_tests_filter_all(...), ['needs_charset' => true, 'needs_environment' => true, 'needs_context' => true, 'needs_is_sandboxed' => true]));
$env->addFilter(new TwigFilter('magic_static', __NAMESPACE__.'\ChildMagicCallStub::magicStaticCall'));
$env->addExtension(new FilterTestExtension());
$env->addExtension(self::createExtension());
@@ -213,6 +223,14 @@ function twig_tests_filter_barbar($context, $string, $arg1 = null, $arg2 = null,
{
}
function twig_tests_filter_sandbox(bool $isSandboxed, $string)
{
}
function twig_tests_filter_all(string $charset, Environment $env, array $context, bool $isSandboxed, $string)
{
}
class ChildMagicCallStub extends ParentMagicCallStub
{
public static function identifier()
+14
View File
@@ -200,6 +200,20 @@ EOF, 'index')));
$this->assertTrue($argumentNodes->getNode(3)->getAttribute('value'));
}
public function testEmbeddedTemplatesHaveSequentialIndices(): void
{
$template = new Source('{% embed "first" %}{% endembed %}{% embed "second" %}{% endembed %}', 'index');
$lexer = new Lexer(new Environment(new ArrayLoader()));
$stream = $lexer->tokenize($template);
$embeds = $this->getParser()
->parse($stream)
->getAttribute('embedded_templates');
$this->assertSame(1, $embeds->getNode(0)->getAttribute('index'));
$this->assertSame(2, $embeds->getNode(1)->getAttribute('index'));
}
protected function getParser()
{
$parser = new Parser(new Environment(new ArrayLoader()));