Repository: spaze/phpstan-disallowed-calls (php)

Issue:
Add flag support for params
I'd like to allow params when a flag is present ia param value like `JSON_HEX_TAG | JSON_HEX_APOS`. Currently that's not possible and a param value is treated as scalar value.

This will need a new param format, one idea is mentioned in https://github.com/spaze/phpstan-disallowed-calls/pull/141#issuecomment-1327289245

After adding the support, `json_decode()` with `JSON_THROW_ON_ERROR` could be added to the "loose" config as originally proposed by @ilazaridis in #141.

Also this should be updated too then
https://github.com/spaze/phpstan-disallowed-calls/blob/332969d0aa67ddb8597c2f1248268d69ebf5f9c7/disallowed-loose-calls.neon#L9-L12

Pull request:
Support flag/bitmask params
Adding support for flag/bitmask params when allowing previously disallowed calls

Close #142

Patch:
diff --git a/README.md b/README.md
index a8aadb5..45e00d7 100644
--- a/README.md
+++ b/README.md
@@ -304,6 +304,8 @@ parameters:
 
 The function or method names support [fnmatch()](https://www.php.net/function.fnmatch) patterns.
 
+### Allow with specified parameters only
+
 You can also narrow down the allowed items when called with some parameters (doesn't apply to constants for obvious reasons). For example, you want to disallow calling `print_r()` but want to allow `print_r(..., true)`.
 This can be done with optional `allowParamsInAllowed` or `allowParamsAnywhere` configuration keys:
 
@@ -364,7 +366,7 @@ means that you should use (`...` means any value):
 
 Such configuration only makes sense when both the parameters of `log()` are optional. If they are required, omitting them would result in an error already detected by PHPStan itself.
 
-## Allow calls except when a param has a specified value
+### Allow calls except when a param has a specified value
 
 Sometimes, it's handy to disallow a function or a method call only when a parameter matches but allow it otherwise. For example the `hash()` function, it's fine using it with algorithm families like SHA-2 & SHA-3 (not for passwords though) but you'd like PHPStan to report when it's used with MD5 like `hash('md5', ...)`.
 You can use `allowExceptParams` (or `disallowParams`), `allowExceptCaseInsensitiveParams` (or `disallowCaseInsensitiveParams`), `allowExceptParamsInAllowed` (or `disallowParamsInAllowed`) config options to disallow only some calls:
@@ -469,6 +471,35 @@ parameters:
 
 But because the "positional _or_ named" limitation described above applies here as well, I generally don't recommend using these shortcuts and instead recommend specifying both `position` and `name` keys.
 
+### Allow with specified parameter flags only
+
+Some functions can be called with _flags_ or _bitmasks_, for example
+
+```php
+json_encode($foo, JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT);
+```
+Let's say you want to disallow `json_encode()` except when called with `JSON_HEX_APOS` (integer `4`) flag. In the call above, the value of the second parameter (`JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT`) is `13` (`1 | 4 | 8`).
+For the extension to be able to "find" the `4` in `13`, you need to use the `ParamFlags` family of config options:
+
+- `allowParamFlagsInAllowed`
+- `allowParamFlagsAnywhere`
+- `allowExceptParamFlagsInAllowed` or `disallowParamFlagsInAllowed`
+- `allowExceptParamFlags` or `disallowParamFlags`
+
+They work like their non-flags `Param` counterparts except they're looking if specific bits in the mask parameter are set.
+
+The `json_encode()` example mentioned above would look like the following snippet:
+
+```neon
+parameters:
+    disallowedFunctionCalls:
+            function: 'json_encode'
+            allowParamFlagsAnywhere:
+                -
+                    position: 2
+                    value: ::JSON_HEX_APOS
+```
+
 ## Case-(in)sensitivity
 
 Function names, method names, class names, namespaces are matched irrespective of their case (disallowing `print_r` will also find `print_R` calls), while anything else like constants, file names, paths are not.
diff --git a/phpstan.neon b/phpstan.neon
index a359bc9..96211dc 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -5,7 +5,8 @@ parameters:
 	typeAliases:
 		CallParamConfig: 'array<int|string, int|bool|string|array{position:int, value?:int|bool|string, name?:string}>'
 		CallParamAnyValueConfig: 'array<int|string, int|array{position:int, value?:int|bool|string, name?:string}>'
-		ForbiddenCallsConfig: 'array<array{function?:string, method?:string, message?:string, allowIn?:string[], allowExceptIn?:string[], disallowIn?:string[], allowInFunctions?:string[], allowInMethods?:string[], allowExceptInFunctions?:string[], allowExceptInMethods?:string[], disallowInFunctions?:string[], disallowInMethods?:string[], allowParamsInAllowed?:CallParamConfig, allowParamsInAllowedAnyValue?:CallParamAnyValueConfig, allowParamsAnywhere?:CallParamConfig, allowParamsAnywhereAnyValue?:CallParamAnyValueConfig, allowExceptParamsInAllowed?:CallParamConfig, disallowParamsInAllowed?:CallParamConfig, allowExceptParams?:CallParamConfig, disallowParams?:CallParamConfig, allowExceptCaseInsensitiveParams?:CallParamConfig, disallowCaseInsensitiveParams?:CallParamConfig, errorIdentifier?:string, errorTip?:string}>'
+		CallParamFlagAnyValueConfig: 'array<int|string, int|array{position:int, value?:int, name?:string}>'
+		ForbiddenCallsConfig: 'array<array{function?:string, method?:string, message?:string, allowIn?:string[], allowExceptIn?:string[], disallowIn?:string[], allowInFunctions?:string[], allowInMethods?:string[], allowExceptInFunctions?:string[], allowExceptInMethods?:string[], disallowInFunctions?:string[], disallowInMethods?:string[], allowParamsInAllowed?:CallParamConfig, allowParamsInAllowedAnyValue?:CallParamAnyValueConfig, allowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, allowParamsAnywhere?:CallParamConfig, allowParamsAnywhereAnyValue?:CallParamAnyValueConfig, allowParamFlagsAnywhere?:CallParamFlagAnyValueConfig, allowExceptParamsInAllowed?:CallParamConfig, allowExceptParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamFlagsInAllowed?:CallParamFlagAnyValueConfig, disallowParamsInAllowed?:CallParamConfig, allowExceptParams?:CallParamConfig, disallowParams?:CallParamConfig, allowExceptParamFlags?:CallParamFlagAnyValueConfig, disallowParamFlags?:CallParamFlagAnyValueConfig, allowExceptCaseInsensitiveParams?:CallParamConfig, disallowCaseInsensitiveParams?:CallParamConfig, errorIdentifier?:string, errorTip?:string}>'
 
 includes:
 	- vendor/phpstan/phpstan/conf/bleedingEdge.neon
diff --git a/src/DisallowedCallFactory.php b/src/DisallowedCallFactory.php
index 6bcfd63..8a98565 100644
--- a/src/DisallowedCallFactory.php
+++ b/src/DisallowedCallFactory.php
@@ -4,11 +4,13 @@ declare(strict_types = 1);
 namespace Spaze\PHPStan\Rules\Disallowed;
 
 use PHPStan\ShouldNotHappenException;
-use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParam;
-use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamExceptCaseInsensitiveValue;
-use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamExceptValue;
-use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamWithAnyValue;
-use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamWithValue;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValue;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValueValueAny;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValueValueCaseInsensitiveExcept;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValueValueExcept;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValueValueFlagExcept;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValueValueFlagSpecific;
+use Spaze\PHPStan\Rules\Disallowed\Params\DisallowedCallParamValueValueSpecific;
 
 class DisallowedCallFactory
 {
@@ -38,25 +40,37 @@ class DisallowedCallFactory
 					$allowExceptInCalls[] = $this->normalizeCall($disallowedCall);
 				}
 				foreach ($disallowed['allowParamsInAllowed'] ?? [] as $param => $value) {
-					$allowParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamWithValue::class, $param, $value);
+					$allowParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamValueValueSpecific::class, $param, $value);
 				}
 				foreach ($disallowed['allowParamsInAllowedAnyValue'] ?? [] as $param => $value) {
-					$allowParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamWithAnyValue::class, $param, $value);
+					$allowParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamValueValueAny::class, $param, $value);
+				}
+				foreach ($disallowed['allowParamFlagsInAllowed'] ?? [] as $param => $value) {
+					$allowParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamValueValueFlagSpecific::class, $param, $value);
 				}
 				foreach ($disallowed['allowParamsAnywhere'] ?? [] as $param => $value) {
-					$allowParamsAnywhere[$param] = $this->paramFactory(DisallowedCallParamWithValue::class, $param, $value);
+					$allowParamsAnywhere[$param] = $this->paramFactory(DisallowedCallParamValueValueSpecific::class, $param, $value);
 				}
 				foreach ($disallowed['allowParamsAnywhereAnyValue'] ?? [] as $param => $value) {
-					$allowParamsAnywhere[$param] = $this->paramFactory(DisallowedCallParamWithAnyValue::class, $param, $value);
+					$allowParamsAnywhere[$param] = $this->paramFactory(DisallowedCallParamValueValueAny::class, $param, $value);
+				}
+				foreach ($disallowed['allowParamFlagsAnywhere'] ?? [] as $param => $value) {
+					$allowParamsAnywhere[$param] = $this->paramFactory(DisallowedCallParamValueValueFlagSpecific::class, $param, $value);
 				}
 				foreach ($disallowed['allowExceptParamsInAllowed'] ?? $disallowed['disallowParamsInAllowed'] ?? [] as $param => $value) {
-					$allowExceptParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamExceptValue::class, $param, $value);
+					$allowExceptParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamValueValueExcept::class, $param, $value);
+				}
+				foreach ($disallowed['allowExceptParamFlagsInAllowed'] ?? $disallowed['disallowParamFlagsInAllowed'] ?? [] as $param => $value) {
+					$allowExceptParamsInAllowed[$param] = $this->paramFactory(DisallowedCallParamValueValueFlagExcept::class, $param, $value);
 				}
 				foreach ($disallowed['allowExceptParams'] ?? $disallowed['disallowParams'] ?? [] as $param => $value) {
-					$allowExceptParams[$param] = $this->paramFactory(DisallowedCallParamExceptValue::class, $param, $value);
+					$allowExceptParams[$param] = $this->paramFactory(DisallowedCallParamValueValueExcept::class, $param, $value);
+				}
+				foreach ($disallowed['allowExceptParamFlags'] ?? $disallowed['disallowParamFlags'] ?? [] as $param => $value) {
+					$allowExceptParams[$param] = $this->paramFactory(DisallowedCallParamValueValueFlagExcept::class, $param, $value);
 				}
 				foreach ($disallowed['allowExceptCaseInsensitiveParams'] ?? $disallowed['disallowCaseInsensitiveParams'] ?? [] as $param => $value) {
-					$allowExceptParams[$param] = $this->paramFactory(DisallowedCallParamExceptCaseInsensitiveValue::class, $param, $value);
+					$allowExceptParams[$param] = $this->paramFactory(DisallowedCallParamValueValueCaseInsensitiveExcept::class, $param, $value);
 				}
 				$disallowedCall = new DisallowedCall(
 					$this->normalizeCall($call),
@@ -87,20 +101,20 @@ class DisallowedCallFactory
 
 
 	/**
-	 * @template T of DisallowedCallParam
+	 * @template T of DisallowedCallParamValue
 	 * @param class-string<T> $class
 	 * @param int|string $key
 	 * @param int|bool|string|null|array{position:int, value?:int|bool|string, name?:string} $value
 	 * @return T
 	 */
-	private function paramFactory(string $class, $key, $value): DisallowedCallParam
+	private function paramFactory(string $class, $key, $value): DisallowedCallParamValue
 	{
 		if (is_numeric($key)) {
 			if (is_array($value)) {
 				$paramPosition = $value['position'];
 				$paramName = $value['name'] ?? null;
 				$paramValue = $value['value'] ?? null;
-			} elseif ($class === DisallowedCallParamWithAnyValue::class) {
+			} elseif ($class === DisallowedCallParamValueValueAny::class) {
 				if (is_numeric($value)) {
 					$paramPosition = $value;
 					$paramName = null;
diff --git a/src/DisallowedHelper.php b/src/DisallowedHelper.php
index 26e1f6d..ad1793b 100644
--- a/src/DisallowedHelper.php
+++ b/src/DisallowedHelper.php
@@ -120,6 +120,12 @@ class DisallowedHelper
 	}
 
 
+	/**
+	 * @param CallLike $node
+	 * @param Scope $scope
+	 * @param DisallowedCallParam $param
+	 * @return Type|null
+	 */
 	private function getArgType(CallLike $node, Scope $scope, DisallowedCallParam $param): ?Type
 	{
 		foreach ($node->getArgs() as $arg) {
diff --git a/src/Params/DisallowedCallParam.php b/src/Params/DisallowedCallParam.php
index f03685c..cc0131c 100644
--- a/src/Params/DisallowedCallParam.php
+++ b/src/Params/DisallowedCallParam.php
@@ -5,53 +5,21 @@ namespace Spaze\PHPStan\Rules\Disallowed\Params;
 
 use PHPStan\Type\ConstantScalarType;
 
-abstract class DisallowedCallParam
+interface DisallowedCallParam
 {
 
-	/** @var ?int */
-	private $position;
+	public function matches(ConstantScalarType $type): bool;
 
-	/** @var ?string */
-	private $name;
 
-	/** @var int|bool|string|null */
-	private $value;
+	public function getPosition(): ?int;
 
 
-	abstract public function matches(ConstantScalarType $type): bool;
+	public function getName(): ?string;
 
 
 	/**
-	 * @param int|null $position
-	 * @param string|null $name
-	 * @param int|bool|string|null $value
+	 * @return int|bool|string|null
 	 */
-	final public function __construct(?int $position, ?string $name, $value)
-	{
-		$this->position = $position;
-		$this->name = $name;
-		$this->value = $value;
-	}
-
-
-	public function getPosition(): ?int
-	{
-		return $this->position;
-	}
-
-
-	public function getName(): ?string
-	{
-		return $this->name;
-	}
-
-
-	/**
-	 * @return bool|int|string|null
-	 */
-	public function getValue()
-	{
-		return $this->value;
-	}
+	public function getValue();
 
 }
diff --git a/src/Params/DisallowedCallParamValue.php b/src/Params/DisallowedCallParamValue.php
new file mode 100644
index 0000000..9eae19b
--- /dev/null
+++ b/src/Params/DisallowedCallParamValue.php
@@ -0,0 +1,60 @@
+<?php
+declare(strict_types = 1);
+
+namespace Spaze\PHPStan\Rules\Disallowed\Params;
+
+use PHPStan\Type\ConstantScalarType;
+
+/**
+ * @template T of int|bool|string|null
+ */
+abstract class DisallowedCallParamValue implements DisallowedCallParam
+{
+
+	/** @var ?int */
+	private $position;
+
+	/** @var ?string */
+	private $name;
+
+	/** @var T */
+	private $value;
+
+
+	abstract public function matches(ConstantScalarType $type): bool;
+
+
+	/**
+	 * @param int|null $position
+	 * @param string|null $name
+	 * @param T $value
+	 */
+	final public function __construct(?int $position, ?string $name, $value)
+	{
+		$this->position = $position;
+		$this->name = $name;
+		$this->value = $value;
+	}
+
+
+	public function getPosition(): ?int
+	{
+		return $this->position;
+	}
+
+
+	public function getName(): ?string
+	{
+		return $this->name;
+	}
+
+
+	/**
+	 * @return T
+	 */
+	public function getValue()
+	{
+		return $this->value;
+	}
+
+}
diff --git a/src/Params/DisallowedCallParamWithAnyValue.php b/src/Params/DisallowedCallParamValueValueAny.php
similarity index 58%
rename from src/Params/DisallowedCallParamWithAnyValue.php
rename to src/Params/DisallowedCallParamValueValueAny.php
index ebcf805..defeeaf 100644
--- a/src/Params/DisallowedCallParamWithAnyValue.php
+++ b/src/Params/DisallowedCallParamValueValueAny.php
@@ -5,7 +5,10 @@ namespace Spaze\PHPStan\Rules\Disallowed\Params;
 
 use PHPStan\Type\ConstantScalarType;
 
-final class DisallowedCallParamWithAnyValue extends DisallowedCallParam
+/**
+ * @extends DisallowedCallParamValue<int|bool|string|null>
+ */
+final class DisallowedCallParamValueValueAny extends DisallowedCallParamValue
 {
 
 	public function matches(ConstantScalarType $type): bool
diff --git a/src/Params/DisallowedCallParamExceptCaseInsensitiveValue.php b/src/Params/DisallowedCallParamValueValueCaseInsensitiveExcept.php
similarity index 73%
rename from src/Params/DisallowedCallParamExceptCaseInsensitiveValue.php
rename to src/Params/DisallowedCallParamValueValueCaseInsensitiveExcept.php
index 254323d..8820551 100644
--- a/src/Params/DisallowedCallParamExceptCaseInsensitiveValue.php
+++ b/src/Params/DisallowedCallParamValueValueCaseInsensitiveExcept.php
@@ -6,7 +6,10 @@ namespace Spaze\PHPStan\Rules\Disallowed\Params;
 use PHPStan\Type\Constant\ConstantStringType;
 use PHPStan\Type\ConstantScalarType;
 
-class DisallowedCallParamExceptCaseInsensitiveValue extends DisallowedCallParam
+/**
+ * @extends DisallowedCallParamValue<int|bool|string|null>
+ */
+class DisallowedCallParamValueValueCaseInsensitiveExcept extends DisallowedCallParamValue
 {
 
 	public function matches(ConstantScalarType $type): bool
diff --git a/src/Params/DisallowedCallParamExceptValue.php b/src/Params/DisallowedCallParamValueValueExcept.php
similarity index 62%
rename from src/Params/DisallowedCallParamExceptValue.php
rename to src/Params/DisallowedCallParamValueValueExcept.php
index a6c3331..333531a 100644
--- a/src/Params/DisallowedCallParamExceptValue.php
+++ b/src/Params/DisallowedCallParamValueValueExcept.php
@@ -5,7 +5,10 @@ namespace Spaze\PHPStan\Rules\Disallowed\Params;
 
 use PHPStan\Type\ConstantScalarType;
 
-class DisallowedCallParamExceptValue extends DisallowedCallParam
+/**
+ * @extends DisallowedCallParamValue<int|bool|string|null>
+ */
+class DisallowedCallParamValueValueExcept extends DisallowedCallParamValue
 {
 
 	public function matches(ConstantScalarType $type): bool
diff --git a/src/Params/DisallowedCallParamValueValueFlagExcept.php b/src/Params/DisallowedCallParamValueValueFlagExcept.php
new file mode 100644
index 0000000..46ec5d5
--- /dev/null
+++ b/src/Params/DisallowedCallParamValueValueFlagExcept.php
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types = 1);
+
+namespace Spaze\PHPStan\Rules\Disallowed\Params;
+
+use PHPStan\Type\Constant\ConstantIntegerType;
+use PHPStan\Type\ConstantScalarType;
+
+/**
+ * @extends DisallowedCallParamValue<int>
+ */
+class DisallowedCallParamValueValueFlagExcept extends DisallowedCallParamValue
+{
+
+	public function matches(ConstantScalarType $type): bool
+	{
+		if (!$type instanceof ConstantIntegerType) {
+			return false;
+		}
+		return ($this->getValue() & $type->getValue()) === 0;
+	}
+
+}
diff --git a/src/Params/DisallowedCallParamValueValueFlagSpecific.php b/src/Params/DisallowedCallParamValueValueFlagSpecific.php
new file mode 100644
index 0000000..d9e8ebb
--- /dev/null
+++ b/src/Params/DisallowedCallParamValueValueFlagSpecific.php
@@ -0,0 +1,23 @@
+<?php
+declare(strict_types = 1);
+
+namespace Spaze\PHPStan\Rules\Disallowed\Params;
+
+use PHPStan\Type\Constant\ConstantIntegerType;
+use PHPStan\Type\ConstantScalarType;
+
+/**
+ * @extends DisallowedCallParamValue<int>
+ */
+class DisallowedCallParamValueValueFlagSpecific extends DisallowedCallParamValue
+{
+
+	public function matches(ConstantScalarType $type): bool
+	{
+		if (!$type instanceof ConstantIntegerType) {
+			return false;
+		}
+		return ($this->getValue() & $type->getValue()) !== 0;
+	}
+
+}
diff --git a/src/Params/DisallowedCallParamWithValue.php b/src/Params/DisallowedCallParamValueValueSpecific.php
similarity index 62%
rename from src/Params/DisallowedCallParamWithValue.php
rename to src/Params/DisallowedCallParamValueValueSpecific.php
index 352367f..abd765f 100644
--- a/src/Params/DisallowedCallParamWithValue.php
+++ b/src/Params/DisallowedCallParamValueValueSpecific.php
@@ -5,7 +5,10 @@ namespace Spaze\PHPStan\Rules\Disallowed\Params;
 
 use PHPStan\Type\ConstantScalarType;
 
-class DisallowedCallParamWithValue extends DisallowedCallParam
+/**
+ * @extends DisallowedCallParamValue<int|bool|string|null>
+ */
+class DisallowedCallParamValueValueSpecific extends DisallowedCallParamValue
 {
 
 	public function matches(ConstantScalarType $type): bool


Test patch:
diff --git a/tests/Calls/FunctionCallsTest.php b/tests/Calls/FunctionCallsTest.php
index 0f0b016..a5f8b3f 100644
--- a/tests/Calls/FunctionCallsTest.php
+++ b/tests/Calls/FunctionCallsTest.php
@@ -146,6 +146,20 @@ class FunctionCallsTest extends RuleTestCase
 						3,
 					],
 				],
+				[
+					'function' => 'htmlspecialchars()',
+					'allowIn' => [
+						'../src/disallowed-allowed/*.php',
+						'../src/*-allow/*.*',
+					],
+					'allowParamFlagsInAllowed' => [
+						[
+							'position' => 2,
+							'name' => 'flags',
+							'value' => ENT_QUOTES,
+						],
+					],
+				],
 			]
 		);
 	}
@@ -218,6 +232,26 @@ class FunctionCallsTest extends RuleTestCase
 				'Calling header() is forbidden, because reasons',
 				62,
 			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				67,
+			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				68,
+			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				69,
+			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				70,
+			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				71,
+			],
 		]);
 		// Based on the configuration above, no errors in this file:
 		$this->analyse([__DIR__ . '/../src/disallowed-allow/functionCalls.php'], [
@@ -241,6 +275,14 @@ class FunctionCallsTest extends RuleTestCase
 				'Calling header() is forbidden, because reasons',
 				63,
 			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				67,
+			],
+			[
+				'Calling htmlspecialchars() is forbidden, because reasons',
+				68,
+			],
 		]);
 	}
 
diff --git a/tests/src/disallowed-allow/functionCalls.php b/tests/src/disallowed-allow/functionCalls.php
index ab19651..0b457cc 100644
--- a/tests/src/disallowed-allow/functionCalls.php
+++ b/tests/src/disallowed-allow/functionCalls.php
@@ -62,3 +62,10 @@ setcookie('foo', 'bar', 0, '/');
 header('foo: bar');
 header('foo: bar', true);
 header('foo: bar', false, 303);
+
+// allowed only with ENT_QUOTES
+htmlspecialchars('foo');
+htmlspecialchars('foo', ENT_DISALLOWED);
+htmlspecialchars('foo', ENT_QUOTES);
+htmlspecialchars('foo', ENT_DISALLOWED | ENT_QUOTES);
+htmlspecialchars('foo', ENT_DISALLOWED | ENT_QUOTES | ENT_HTML5);
diff --git a/tests/src/disallowed/functionCalls.php b/tests/src/disallowed/functionCalls.php
index b32e142..5dff857 100644
--- a/tests/src/disallowed/functionCalls.php
+++ b/tests/src/disallowed/functionCalls.php
@@ -62,3 +62,10 @@ setcookie('foo', 'bar', 0, '/');
 header('foo: bar');
 header('foo: bar', true);
 header('foo: bar', false, 303);
+
+// all disallowed
+htmlspecialchars('foo');
+htmlspecialchars('foo', ENT_DISALLOWED);
+htmlspecialchars('foo', ENT_QUOTES);
+htmlspecialchars('foo', ENT_DISALLOWED | ENT_QUOTES);
+htmlspecialchars('foo', ENT_DISALLOWED | ENT_QUOTES | ENT_HTML5);