diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 6cf75b3..ff548ad 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -30,6 +30,8 @@ use TypePHP\Internal\Validator\TypeValidatorRegistry; /** + * @phpstan-import-type FunctionContract from DocblockParser + * * @internal Evaluates function and method parameter contract validations (including dynamic @method calls via __call / __callStatic). */ final class ParamChecker @@ -102,23 +104,7 @@ private static function isUnconstrained(TypeNode $typeNode): bool /** * @param array $vars - * @param array{ - * types: array, - * templates: array, - * classTemplates: array, - * return: ?TypeNode, - * aliases: array, - * hasParamContract: bool, - * hasReturnContract: bool, - * paramsUseGenerics: bool, - * returnUsesGenerics: bool, - * returnUsesMethodTemplates: bool, - * returnIsThis: bool, - * returnIsDynamic: bool, - * allParamsUnconstrained: bool, - * returnUnconstrained: bool, - * isSimple: bool - * }|null $contract Pre-resolved contract to avoid re-parsing + * @param FunctionContract|null $contract Pre-resolved contract to avoid re-parsing */ public static function checkParams( string $function, @@ -170,9 +156,10 @@ public static function checkParams( $classTemplates = $contract['classTemplates'] ?? []; $aliases = $contract['aliases']; $hasMethodTemplates = (\count($methodTemplates) > 0); + $sensitiveParams = $contract['sensitiveParams'] ?? []; if (! $paramsUseGenerics && ! $hasMethodTemplates && \count($aliases) === 0) { - return self::validateSimpleParams($contract['types'], $vars, $effectiveFunction, $registry); + return self::validateSimpleParams($contract['types'], $vars, $effectiveFunction, $registry, $sensitiveParams); } self::prepareGenericBindings($effectiveFunction, $methodTemplates, $thisObj, $classTemplates, $thisOrClass); @@ -203,7 +190,8 @@ public static function checkParams( $declaredTemplates, $registry, $classTemplates, - $thisOrClass + $thisOrClass, + $sensitiveParams ); } @@ -212,12 +200,14 @@ public static function checkParams( * * @param array $types * @param array $vars + * @param array $sensitiveParams */ private static function validateSimpleParams( array $types, array $vars, string $effectiveFunction, - TypeValidatorRegistry $registry + TypeValidatorRegistry $registry, + array $sensitiveParams = [] ): ?ErrorMessage { foreach ($types as $paramName => $typeNode) { if (isset($vars[$paramName]) || \array_key_exists($paramName, $vars)) { @@ -231,7 +221,9 @@ private static function validateSimpleParams( if (self::isUnconstrained($typeNode)) { continue; } - $err = $registry->validate($vars[$paramName], $typeNode, ''); + + $isSensitive = $sensitiveParams[$paramName] ?? false; + $err = $registry->validate($vars[$paramName], $typeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($effectiveFunction . '(): Argument $' . $paramName . $err->getMessage()); } @@ -310,6 +302,7 @@ private static function resolveBaseTypes( * @param array $boundTemplates * @param array $declaredTemplates * @param array $classTemplates + * @param array $sensitiveParams */ private static function validateAllParameters( array $contractTypes, @@ -323,7 +316,8 @@ private static function validateAllParameters( array $declaredTemplates, TypeValidatorRegistry $registry, array $classTemplates, - object|string|null $thisOrClass = null + object|string|null $thisOrClass = null, + array $sensitiveParams = [] ): ?ErrorMessage { foreach ($contractTypes as $paramName => $_) { if (! isset($vars[$paramName]) && ! \array_key_exists($paramName, $vars)) { @@ -334,6 +328,8 @@ private static function validateAllParameters( ? TemplateManager::getBoundTemplates($effectiveFunction, $thisOrClass, $allTemplates) : $boundTemplates; + $isSensitive = $sensitiveParams[$paramName] ?? false; + $err = self::validateSingleParam( $paramName, $baseTypes[$paramName], @@ -347,7 +343,8 @@ private static function validateAllParameters( $registry, $classTemplates, $vars, - $thisOrClass + $thisOrClass, + $isSensitive ); if ($err !== null) { @@ -911,7 +908,8 @@ private static function validateSingleParam( TypeValidatorRegistry $registry, array $classTemplates = [], array $vars = [], - object|string|null $thisOrClass = null + object|string|null $thisOrClass = null, + bool $isSensitive = false ): ?ErrorMessage { if ( $typeNode instanceof ConditionalTypeForParameterNode || @@ -941,14 +939,14 @@ private static function validateSingleParam( } if ($typeNode instanceof GenericTypeNode && self::isClassStringTemplate($typeNode, $templates)) { - return self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $classTemplates); + return self::resolveClassStringTemplate($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $classTemplates, $isSensitive); } if (self::getTemplateName($typeNode, $templates) !== null) { return self::resolveTemplateParam($typeNode, $val, $paramName, $effectiveFunction, $thisObj, $templates, $registry, $classTemplates, $thisOrClass); } - return $registry->validate($val, $typeNode, $effectiveFunction . '(): Argument $' . $paramName); + return $registry->validate($val, $typeNode, $effectiveFunction . '(): Argument $' . $paramName, $isSensitive); } /** @@ -1078,7 +1076,8 @@ private static function resolveClassStringTemplate( string $function, ?object $thisObj, array $templates, - array $classTemplates = [] + array $classTemplates = [], + bool $isSensitive = false ): ?ErrorMessage { /** @var IdentifierTypeNode $innerType */ $innerType = $typeNode->genericTypes[0]; @@ -1089,7 +1088,7 @@ private static function resolveClassStringTemplate( if (! TemplateManager::isBound($function, $targetObj, $templateName)) { if (! \is_string($val) || ! ClassNameValidator::isValidClassString($val)) { - return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($val) . ' given'); + return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($val, $isSensitive) . ' given'); } if ($templateNode->bound !== null) { @@ -1098,8 +1097,9 @@ private static function resolveClassStringTemplate( $resolvedBound = SpecialTypeResolver::resolve($templateNode->bound, $function, $thisObj); if (! self::checkClassStringSatisfiesBound($val, $resolvedBound)) { $boundDisplay = (string) $resolvedBound; + $displayVal = $isSensitive ? 'string given' : "'" . $val . "' given"; - return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundDisplay . ", '" . $val . "' given"); + return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' (class-string<' . $templateName . '>) must be a class-string of ' . $boundDisplay . ', ' . $displayVal); } } } @@ -1110,7 +1110,7 @@ private static function resolveClassStringTemplate( $expectedTypeNode = TemplateManager::getBoundType($function, $targetObj, $templateName); if ($expectedTypeNode !== null) { if (! \is_string($val) || ! self::checkClassStringSatisfiesBound($val, $expectedTypeNode)) { - $valStr = TypeFormatter::formatGivenValue($val); + $valStr = TypeFormatter::formatGivenValue($val, $isSensitive); $targetDisplay = (string) $expectedTypeNode; return ErrorFactory::createError($function . '(): Argument $' . $paramName . ' must be a class-string of ' . $targetDisplay . ', ' . $valStr . ' given'); diff --git a/src/Internal/Checker/ReturnChecker.php b/src/Internal/Checker/ReturnChecker.php index 9da7ec1..03a1aef 100644 --- a/src/Internal/Checker/ReturnChecker.php +++ b/src/Internal/Checker/ReturnChecker.php @@ -21,6 +21,8 @@ use TypePHP\Internal\Wrapper\CallableWrapper; /** + * @phpstan-import-type FunctionContract from DocblockParser + * * @internal Evaluates function and method return contract validations (including dynamic @method calls via __call / __callStatic). */ final class ReturnChecker @@ -81,23 +83,7 @@ public static function isReturnUnconstrained(string $effectiveFunction): bool /** * @param array $vars - * @param array{ - * types: array, - * templates: array, - * classTemplates: array, - * return: ?TypeNode, - * aliases: array, - * hasParamContract: bool, - * hasReturnContract: bool, - * paramsUseGenerics: bool, - * returnUsesGenerics: bool, - * returnUsesMethodTemplates: bool, - * returnIsThis: bool, - * returnIsDynamic: bool, - * allParamsUnconstrained: bool, - * returnUnconstrained: bool, - * isSimple: bool - * }|null $contract Pre-resolved contract to avoid re-parsing + * @param FunctionContract|null $contract Pre-resolved contract to avoid re-parsing */ public static function checkReturn( string $function, @@ -145,7 +131,6 @@ public static function checkReturn( return $value; } - // Use pre-resolved contract or parse $contract ??= DocblockParser::parse($effectiveFunction); if (! ($contract['hasReturnContract'] ?? ($contract['return'] !== null))) { @@ -236,18 +221,7 @@ private static function handleMagicReturn( * @param array $vars * @param array $aliases * @param array $templates - * @param array{ - * return?: TypeNode|null, - * hasReturnContract?: bool, - * returnIsThis?: bool, - * returnIsDynamic?: bool, - * aliases?: array, - * templates?: array, - * classTemplates?: array, - * allParamsUnconstrained?: bool, - * returnUnconstrained?: bool, - * isSimple?: bool - * } $contract + * @param FunctionContract|array{} $contract */ private static function evaluateReturn( TypeNode $returnTypeNode, @@ -348,7 +322,6 @@ private static function evaluateReturn( } } - // Fast-path: if return type is mixed or array, skip validation. if ($resolvedType instanceof IdentifierTypeNode) { $lower = strtolower($resolvedType->name); if ($lower === 'mixed' || $lower === 'array') { diff --git a/src/Internal/Diagnostic/TypeFormatter.php b/src/Internal/Diagnostic/TypeFormatter.php index c536ffa..4004858 100644 --- a/src/Internal/Diagnostic/TypeFormatter.php +++ b/src/Internal/Diagnostic/TypeFormatter.php @@ -9,8 +9,12 @@ */ final class TypeFormatter { - public static function formatGivenValue(mixed $value): string + public static function formatGivenValue(mixed $value, bool $isSensitive = false): string { + if ($isSensitive) { + return get_debug_type($value); + } + if (\is_int($value)) { if ($value < 0) { return "negative int ($value)"; diff --git a/src/Internal/Docblock/DocblockParser.php b/src/Internal/Docblock/DocblockParser.php index 105a3eb..1cadab0 100644 --- a/src/Internal/Docblock/DocblockParser.php +++ b/src/Internal/Docblock/DocblockParser.php @@ -28,11 +28,34 @@ use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\FileFilter; -use TypePHP\Internal\Util\IgnoreManager; use TypePHP\Internal\Util\StubManager; use TypePHP\Internal\Validator\TypeValidatorRegistry; /** + * @phpstan-type FunctionContract array{ + * types: array, + * paramOuts: array, + * selfOut: ?TypeNode, + * templates: array, + * classTemplates: array, + * allTemplates?: array, + * return: ?TypeNode, + * aliases: array, + * sensitiveParams: array, + * hasParamContract: bool, + * hasParamOutContract: bool, + * hasSelfOutContract: bool, + * hasReturnContract: bool, + * paramsUseGenerics: bool, + * returnUsesGenerics: bool, + * returnUsesMethodTemplates: bool, + * returnIsThis: bool, + * returnIsDynamic: bool, + * allParamsUnconstrained: bool, + * returnUnconstrained: bool, + * isSimple: bool + * } + * * @internal Main orchestrator parsing and caching PHPDoc contracts (@param, @param-out, @return, @template, @phpstan-type, @var, stubs). */ final class DocblockParser @@ -40,27 +63,7 @@ final class DocblockParser /** * Cache for resolved contract metadata. * - * @var array, - * paramOuts: array, - * selfOut: ?TypeNode, - * templates: array, - * classTemplates: array, - * return: ?TypeNode, - * aliases: array, - * hasParamContract: bool, - * hasParamOutContract: bool, - * hasSelfOutContract: bool, - * hasReturnContract: bool, - * paramsUseGenerics: bool, - * returnUsesGenerics: bool, - * returnUsesMethodTemplates: bool, - * returnIsThis: bool, - * returnIsDynamic: bool, - * allParamsUnconstrained: bool, - * returnUnconstrained: bool, - * isSimple: bool - * }> + * @var array */ private static array $cache = []; @@ -331,27 +334,7 @@ private static function computeContractFlags( /** * Parses PHPDoc contracts for a function or class method. * - * @return array{ - * types: array, - * paramOuts: array, - * selfOut: ?TypeNode, - * templates: array, - * classTemplates: array, - * return: ?TypeNode, - * aliases: array, - * hasParamContract: bool, - * hasParamOutContract: bool, - * hasSelfOutContract: bool, - * hasReturnContract: bool, - * paramsUseGenerics: bool, - * returnUsesGenerics: bool, - * returnUsesMethodTemplates: bool, - * returnIsThis: bool, - * returnIsDynamic: bool, - * allParamsUnconstrained: bool, - * returnUnconstrained: bool, - * isSimple: bool - * } + * @return FunctionContract */ public static function parse(string $function): array { @@ -381,6 +364,7 @@ public static function parse(string $function): array 'classTemplates' => $classTemplates, 'return' => null, 'aliases' => $aliases, + 'sensitiveParams' => [], 'hasParamContract' => false, 'hasParamOutContract' => false, 'hasSelfOutContract' => false, @@ -404,6 +388,7 @@ public static function parse(string $function): array 'classTemplates' => [], 'return' => null, 'aliases' => [], + 'sensitiveParams' => [], 'hasParamContract' => false, 'hasParamOutContract' => false, 'hasSelfOutContract' => false, @@ -431,6 +416,7 @@ public static function parse(string $function): array 'classTemplates' => [], 'return' => null, 'aliases' => [], + 'sensitiveParams' => [], 'hasParamContract' => false, 'hasParamOutContract' => false, 'hasSelfOutContract' => false, @@ -765,7 +751,19 @@ private static function extractRawParamName(object $paramNode): string private static function shouldIgnoreDoc(string $doc): bool { - return Config::isRespectIgnoreTagsEnabled() && IgnoreManager::hasIgnoreDocTag($doc); + return Config::isRespectIgnoreTagsEnabled() && \TypePHP\Internal\Util\IgnoreManager::hasIgnoreDocTag($doc); + } + + private static function hasSensitiveAttribute(\ReflectionParameter $param): bool + { + foreach ($param->getAttributes() as $attr) { + $name = ltrim($attr->getName(), '\\'); + if ($name === 'SensitiveParameter' || str_ends_with($name, '\\SensitiveParameter')) { + return true; + } + } + + return false; } /** @@ -795,27 +793,7 @@ public static function parseClassAliases(string $className): array /** * Orchestrates parsing for class methods across the inheritance hierarchy. * - * @return array{ - * types: array, - * paramOuts: array, - * selfOut: ?TypeNode, - * templates: array, - * classTemplates: array, - * return: ?TypeNode, - * aliases: array, - * hasParamContract: bool, - * hasParamOutContract: bool, - * hasSelfOutContract: bool, - * hasReturnContract: bool, - * paramsUseGenerics: bool, - * returnUsesGenerics: bool, - * returnUsesMethodTemplates: bool, - * returnIsThis: bool, - * returnIsDynamic: bool, - * allParamsUnconstrained: bool, - * returnUnconstrained: bool, - * isSimple: bool - * } + * @return FunctionContract */ private static function parseMethod(\ReflectionMethod $ref): array { @@ -831,6 +809,8 @@ private static function parseMethod(\ReflectionMethod $ref): array $selfOut = null; /** @var array $aliases */ $aliases = []; + /** @var array $sensitiveParams */ + $sensitiveParams = []; $targetClass = (class_exists($ref->class, false) || class_exists($ref->class) || interface_exists($ref->class) || enum_exists($ref->class) || trait_exists($ref->class)) ? new \ReflectionClass($ref->class) @@ -841,7 +821,7 @@ private static function parseMethod(\ReflectionMethod $ref): array self::parseClassLevelDocs($ref->getDeclaringClass(), $classTemplates, $aliases); } - self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases, $paramOuts, $selfOut, $classTemplates); + self::parseMethodHierarchyDocs($ref, $types, $methodTemplates, $returnType, $aliases, $paramOuts, $selfOut, $classTemplates, $sensitiveParams); if ($ref->getName() === '__construct') { self::applyConstructorPromotionFallback($ref, $types, $classTemplates, $aliases); @@ -892,7 +872,6 @@ private static function parseMethod(\ReflectionMethod $ref): array $returnIsDynamic = $returnIsThis || str_contains($retStr, 'static') || str_contains($retStr, '$this'); } - // Compute pre-optimized flags $flags = self::computeContractFlags( $types, $returnType, @@ -911,6 +890,7 @@ private static function parseMethod(\ReflectionMethod $ref): array 'allTemplates' => $allTemplates, 'return' => $returnType, 'aliases' => $aliases, + 'sensitiveParams' => $sensitiveParams, 'hasParamContract' => \count($types) > 0, 'hasParamOutContract' => \count($paramOuts) > 0, 'hasSelfOutContract' => $selfOut !== null, @@ -929,27 +909,7 @@ private static function parseMethod(\ReflectionMethod $ref): array /** * Orchestrates parsing for standalone global or namespaced functions. * - * @return array{ - * types: array, - * paramOuts: array, - * selfOut: ?TypeNode, - * templates: array, - * classTemplates: array, - * return: ?TypeNode, - * aliases: array, - * hasParamContract: bool, - * hasParamOutContract: bool, - * hasSelfOutContract: bool, - * hasReturnContract: bool, - * paramsUseGenerics: bool, - * returnUsesGenerics: bool, - * returnUsesMethodTemplates: bool, - * returnIsThis: bool, - * returnIsDynamic: bool, - * allParamsUnconstrained: bool, - * returnUnconstrained: bool, - * isSimple: bool - * } + * @return FunctionContract */ private static function parseFunction(\ReflectionFunction $ref): array { @@ -958,11 +918,23 @@ private static function parseFunction(\ReflectionFunction $ref): array $templates = []; $returnType = null; $aliases = []; + $sensitiveParams = []; $funcName = $ref->getName(); $stubDoc = StubManager::getFunctionDoc($funcName); $doc = $stubDoc ?? $ref->getDocComment(); + $baseParams = $ref->getParameters(); + $baseParamVariadic = []; + $baseParamObjects = []; + foreach ($baseParams as $p) { + $baseParamVariadic[$p->getName()] = $p->isVariadic(); + $baseParamObjects[$p->getName()] = $p; + if (self::hasSensitiveAttribute($p)) { + $sensitiveParams[$p->getName()] = true; + } + } + if ($doc === false || $doc === null || self::shouldIgnoreDoc($doc)) { return [ 'types' => [], @@ -973,6 +945,7 @@ private static function parseFunction(\ReflectionFunction $ref): array 'allTemplates' => $templates, 'return' => null, 'aliases' => [], + 'sensitiveParams' => $sensitiveParams, 'hasParamContract' => false, 'hasParamOutContract' => false, 'hasSelfOutContract' => false, @@ -995,14 +968,6 @@ private static function parseFunction(\ReflectionFunction $ref): array } DocblockExtractor::extractAliases($phpDocNode, $aliases, $ref); - $baseParams = $ref->getParameters(); - $baseParamVariadic = []; - $baseParamObjects = []; - foreach ($baseParams as $p) { - $baseParamVariadic[$p->getName()] = $p->isVariadic(); - $baseParamObjects[$p->getName()] = $p; - } - foreach (DocblockExtractor::getParamTags($phpDocNode) as $paramName => $paramTag) { $type = $paramTag->type; $pObj = $baseParamObjects[$paramName] ?? null; @@ -1083,7 +1048,6 @@ private static function parseFunction(\ReflectionFunction $ref): array $returnIsDynamic = $returnIsThis || str_contains($retStr, 'static') || str_contains($retStr, '$this'); } - // Compute pre-optimized flags $flags = self::computeContractFlags( $types, $returnType, @@ -1099,8 +1063,10 @@ private static function parseFunction(\ReflectionFunction $ref): array 'selfOut' => null, 'templates' => $templates, 'classTemplates' => [], + 'allTemplates' => $templates, 'return' => $returnType, 'aliases' => $aliases, + 'sensitiveParams' => $sensitiveParams, 'hasParamContract' => \count($types) > 0, 'hasParamOutContract' => \count($paramOuts) > 0, 'hasSelfOutContract' => false, @@ -1206,6 +1172,7 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar * @param array $paramOuts * @param TypeNode|null $selfOut * @param array $classTemplates + * @param array $sensitiveParams */ private static function parseMethodHierarchyDocs( \ReflectionMethod $ref, @@ -1215,7 +1182,8 @@ private static function parseMethodHierarchyDocs( array &$aliases, array &$paramOuts = [], ?TypeNode &$selfOut = null, - array &$classTemplates = [] + array &$classTemplates = [], + array &$sensitiveParams = [] ): void { $hierarchy = HierarchyResolver::getMethodHierarchy($ref); $baseParams = $ref->getParameters(); @@ -1230,6 +1198,9 @@ private static function parseMethodHierarchyDocs( $baseParamSet[$p->getName()] = $idx; $baseParamVariadic[$p->getName()] = $p->isVariadic(); $baseParamObjects[$p->getName()] = $p; + if (self::hasSensitiveAttribute($p)) { + $sensitiveParams[$p->getName()] = true; + } } foreach ($hierarchy as $hierRef) { @@ -1246,6 +1217,22 @@ private static function parseMethodHierarchyDocs( continue; } + $hierParams = $hierRef->getParameters(); + $hierNameToIndex = []; + foreach ($hierParams as $idx => $p) { + $hierNameToIndex[$p->getName()] = $idx; + if (self::hasSensitiveAttribute($p)) { + $targetName = self::resolveTargetParamName( + $p->getName(), + $baseParamSet, + $baseParamNames, + $hierNameToIndex, + $isConstructor + ); + $sensitiveParams[$targetName ?? $p->getName()] = true; + } + } + $doc = $stubDoc ?? $hierRef->getDocComment(); if ($doc === false || $doc === null || self::shouldIgnoreDoc($doc)) { continue; @@ -1260,12 +1247,6 @@ private static function parseMethodHierarchyDocs( } DocblockExtractor::extractAliases($phpDocNode, $aliases, $hierRef); - $hierParams = $hierRef->getParameters(); - $hierNameToIndex = []; - foreach ($hierParams as $idx => $p) { - $hierNameToIndex[$p->getName()] = $idx; - } - $paramTags = DocblockExtractor::getParamTags($phpDocNode); foreach ($paramTags as $paramName => $paramTag) { diff --git a/src/Internal/Validator/ArrayShapeValidator.php b/src/Internal/Validator/ArrayShapeValidator.php index 3a6d923..e28aa80 100644 --- a/src/Internal/Validator/ArrayShapeValidator.php +++ b/src/Internal/Validator/ArrayShapeValidator.php @@ -18,17 +18,17 @@ */ final class ArrayShapeValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { if (! \is_array($value)) { - return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } /** @var ArrayShapeNode $shapeNode */ $shapeNode = $node; if ($shapeNode->kind === ArrayShapeNode::KIND_LIST && \count($value) > 0 && ! array_is_list($value)) { - return ErrorFactory::createError($context . ' must be a list, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a list, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } $knownKeys = []; @@ -64,7 +64,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $matchedKeysCount++; - $err = $registry->validate($value[$key], $item->valueType, ''); + $err = $registry->validate($value[$key], $item->valueType, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . "['" . $key . "']" . $err->getMessage()); } @@ -102,7 +102,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } } - $err = $registry->validate($v, $unsealedValueType, ''); + $err = $registry->validate($v, $unsealedValueType, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . "['{$k}']" . $err->getMessage()); } diff --git a/src/Internal/Validator/ArrayValidator.php b/src/Internal/Validator/ArrayValidator.php index aefcd13..59d869e 100644 --- a/src/Internal/Validator/ArrayValidator.php +++ b/src/Internal/Validator/ArrayValidator.php @@ -21,10 +21,10 @@ */ final class ArrayValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { if (! \is_array($value) && ! ($value instanceof Traversable)) { - return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type array, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } /** @var ArrayTypeNode $arrayNode */ @@ -45,7 +45,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } foreach ($value as $k => $v) { - $err = $registry->validate($v, $arrayNode->type, ''); + $err = $registry->validate($v, $arrayNode->type, '', $isSensitive); if ($err !== null) { $keyStr = \is_string($k) ? "'" . $k . "'" : (string) $k; @@ -57,7 +57,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } foreach ($value as $k => $v) { - $err = $registry->validate($v, $arrayNode->type, ''); + $err = $registry->validate($v, $arrayNode->type, '', $isSensitive); if ($err !== null) { $keyStr = \is_string($k) ? "'" . $k . "'" diff --git a/src/Internal/Validator/ConstValidator.php b/src/Internal/Validator/ConstValidator.php index df989bd..6b790ce 100644 --- a/src/Internal/Validator/ConstValidator.php +++ b/src/Internal/Validator/ConstValidator.php @@ -27,7 +27,7 @@ final class ConstValidator implements TypeValidatorInterface */ private static array $wildcardConstantCache = []; - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { /** @var ConstTypeNode $constTypeNode */ $constTypeNode = $node; @@ -55,7 +55,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali if (! \in_array($value, $allowedValues, strict: true)) { $fqcnPattern = $className !== '' ? "$className::$pattern" : $pattern; - return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . " must be a valid constant matching $fqcnPattern, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -77,14 +77,14 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali // Float Epsilon Comparison: Handles IEEE 754 precision artifacts and int-to-float coercion if (\is_float($expected)) { if ((! \is_float($value) && ! \is_int($value)) || abs((float) $value - $expected) > 1e-9) { - return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; } if ($value !== $expected) { - return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be literal ' . (string) $constExpr . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; diff --git a/src/Internal/Validator/GenericValidator.php b/src/Internal/Validator/GenericValidator.php index 46879f6..479ea90 100644 --- a/src/Internal/Validator/GenericValidator.php +++ b/src/Internal/Validator/GenericValidator.php @@ -64,28 +64,25 @@ final class GenericValidator implements TypeValidatorInterface /** * Validates a value against a GenericTypeNode AST. */ - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { /** @var GenericTypeNode $genericNode */ $genericNode = $node; $baseType = strtolower($genericNode->type->name); return match ($baseType) { - 'int', 'integer' => $this->validateIntRange($value, $genericNode, $context), - 'class-string' => $this->validateClassString($value, $genericNode, $context), - 'list', 'non-empty-list', 'non-empty-array-list' => $this->validateList($value, $genericNode, $context, $registry), - 'array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'iterator' => $this->validateArray($value, $genericNode, $context, $registry), - 'key-of' => $this->validateKeyOf($value, $genericNode, $context, $registry), - 'value-of' => $this->validateValueOf($value, $genericNode, $context, $registry), - 'int-mask' => $this->validateIntMask($value, $genericNode, $context), - 'int-mask-of' => $this->validateIntMaskOf($value, $genericNode, $context), - default => $this->validateObjectGeneric($value, $genericNode, $context), + 'int', 'integer' => $this->validateIntRange($value, $genericNode, $context, $isSensitive), + 'class-string' => $this->validateClassString($value, $genericNode, $context, $isSensitive), + 'list', 'non-empty-list', 'non-empty-array-list' => $this->validateList($value, $genericNode, $context, $registry, $isSensitive), + 'array', 'non-empty-array', 'iterable', 'traversable', 'generator', 'iterator' => $this->validateArray($value, $genericNode, $context, $registry, $isSensitive), + 'key-of' => $this->validateKeyOf($value, $genericNode, $context, $registry, $isSensitive), + 'value-of' => $this->validateValueOf($value, $genericNode, $context, $registry, $isSensitive), + 'int-mask' => $this->validateIntMask($value, $genericNode, $context, $isSensitive), + 'int-mask-of' => $this->validateIntMaskOf($value, $genericNode, $context, $isSensitive), + default => $this->validateObjectGeneric($value, $genericNode, $context, $isSensitive), }; } - /** - * Helper to resolve and cache class or global constant values in static memory. - */ private function resolveConstantValue(string $fqcn, string $constName): mixed { $cacheKey = $fqcn !== '' ? "$fqcn::$constName" : $constName; @@ -114,10 +111,7 @@ private function resolveConstantValue(string $fqcn, string $constName): mixed return self::$constantCache[$cacheKey]; } - /** - * Validates key-of generic structures with O(1) in-memory caching. - */ - private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + private function validateKeyOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { $targetType = $node->genericTypes[0] ?? null; @@ -136,7 +130,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont } } if (! \in_array($value, $validKeys, strict: true)) { - return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -153,7 +147,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont if (\is_array($constValue)) { if ((! \is_int($value) && ! \is_string($value)) || ! \array_key_exists($value, $constValue)) { - return ErrorFactory::createError($context . " must be a key of $cacheKey, " . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . " must be a key of $cacheKey, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -166,7 +160,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont } if (! \in_array($value, self::$enumKeyCache[$enumClass], strict: true)) { - return ErrorFactory::createError($context . " must be a key of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . " must be a key of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -193,7 +187,7 @@ private function validateKeyOf(mixed $value, GenericTypeNode $node, string $cont } if (! \in_array($value, $validKeys, strict: true)) { - return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a key of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -222,10 +216,7 @@ private static function extractKeyFromItem(ArrayShapeItemNode $item): string|int return null; } - /** - * Validates value-of generic structures with O(1) in-memory caching. - */ - private function validateValueOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + private function validateValueOf(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { $targetType = $node->genericTypes[0] ?? null; @@ -239,7 +230,7 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co if (\is_array($constValue)) { if (! \in_array($value, $constValue, strict: true)) { - return ErrorFactory::createError($context . " must be a value of $cacheKey, " . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . " must be a value of $cacheKey, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; @@ -253,34 +244,31 @@ private function validateValueOf(mixed $value, GenericTypeNode $node, string $co } if (! \in_array($value, self::$enumValueCache[$enumClass], strict: true)) { - return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; } - return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . " must be a value of enum $enumClass, " . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } } elseif ($targetType instanceof ArrayShapeNode) { foreach ($targetType->items as $item) { - if ($registry->validate($value, $item->valueType, '') === null) { + if ($registry->validate($value, $item->valueType, '', $isSensitive) === null) { return null; } } - return ErrorFactory::createError($context . ' must be a value of the specified array shape, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a value of the specified array shape, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; } - /** - * Validates int-mask<1, 2, 4> bitmask flags combinations. - */ - private function validateIntMask(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateIntMask(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage { if (! \is_int($value)) { - return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } $allowedMask = 0; @@ -300,19 +288,16 @@ private function validateIntMask(mixed $value, GenericTypeNode $node, string $co } if (($value & ~$allowedMask) !== 0) { - return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; } - /** - * Validates int-mask-of bitmask flags combinations from constant patterns. - */ - private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage { if (! \is_int($value)) { - return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type int (bitmask), ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } $targetType = $node->genericTypes[0] ?? null; @@ -357,19 +342,16 @@ private function validateIntMaskOf(mixed $value, GenericTypeNode $node, string $ } if ($foundFlags && ($value & ~$allowedMask) !== 0) { - return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a valid bitmask combination of the allowed flags, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; } - /** - * Validates integer ranges (e.g. int<1, 100> or int). - */ - private function validateIntRange(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateIntRange(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage { if (! \is_int($value)) { - return ErrorFactory::createError($context . ' must be of type int, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type int, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } $minNode = $node->genericTypes[0] ?? null; @@ -380,7 +362,9 @@ private function validateIntRange(mixed $value, GenericTypeNode $node, string $c if ($minStr !== 'min' && $minStr !== '*') { $minVal = (int) $minStr; if ($value < $minVal) { - return ErrorFactory::createError($context . " must be >= $minVal, $value given"); + $valDisplay = $isSensitive ? 'int given' : "$value given"; + + return ErrorFactory::createError($context . " must be >= $minVal, $valDisplay"); } } } @@ -390,7 +374,9 @@ private function validateIntRange(mixed $value, GenericTypeNode $node, string $c if ($maxStr !== 'max' && $maxStr !== '*') { $maxVal = (int) $maxStr; if ($value > $maxVal) { - return ErrorFactory::createError($context . " must be <= $maxVal, $value given"); + $valDisplay = $isSensitive ? 'int given' : "$value given"; + + return ErrorFactory::createError($context . " must be <= $maxVal, $valDisplay"); } } } @@ -398,13 +384,10 @@ private function validateIntRange(mixed $value, GenericTypeNode $node, string $c return null; } - /** - * Validates class-string parameters against declared class bounds. - */ - private function validateClassString(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateClassString(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage { if (! \is_string($value) || ! ClassNameValidator::isValidClassString($value)) { - return ErrorFactory::createError($context . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a valid class-string, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } $targetClassNode = $node->genericTypes[0] ?? null; @@ -412,10 +395,10 @@ private function validateClassString(mixed $value, GenericTypeNode $node, string return null; } - return $this->validateClassStringBound($value, $targetClassNode, $context); + return $this->validateClassStringBound($value, $targetClassNode, $context, $isSensitive); } - private function validateClassStringBound(string $value, TypeNode $targetNode, string $context): ?ErrorMessage + private function validateClassStringBound(string $value, TypeNode $targetNode, string $context, bool $isSensitive = false): ?ErrorMessage { if ($targetNode instanceof IdentifierTypeNode) { $targetName = $targetNode->name; @@ -426,7 +409,9 @@ private function validateClassStringBound(string $value, TypeNode $targetNode, s if (class_exists($targetName) || interface_exists($targetName) || trait_exists($targetName) || enum_exists($targetName)) { if (! is_a($value, $targetName, allow_string: true)) { - return ErrorFactory::createError($context . ' must be a class-string of ' . $targetName . ", '$value' given"); + $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + + return ErrorFactory::createError($context . ' must be a class-string of ' . $targetName . ", $valDisplay"); } } @@ -435,17 +420,19 @@ private function validateClassStringBound(string $value, TypeNode $targetNode, s if ($targetNode instanceof UnionTypeNode) { foreach ($targetNode->types as $unionType) { - if ($this->validateClassStringBound($value, $unionType, $context) === null) { + if ($this->validateClassStringBound($value, $unionType, $context, $isSensitive) === null) { return null; } } - return ErrorFactory::createError($context . ' must be a class-string of ' . (string) $targetNode . ", '$value' given"); + $valDisplay = $isSensitive ? 'string given' : "'$value' given"; + + return ErrorFactory::createError($context . ' must be a class-string of ' . (string) $targetNode . ", $valDisplay"); } if ($targetNode instanceof IntersectionTypeNode) { foreach ($targetNode->types as $intersectionType) { - $err = $this->validateClassStringBound($value, $intersectionType, $context); + $err = $this->validateClassStringBound($value, $intersectionType, $context, $isSensitive); if ($err !== null) { return $err; } @@ -457,15 +444,12 @@ private function validateClassStringBound(string $value, TypeNode $targetNode, s return null; } - /** - * Validates sequential list structures (e.g. list or non-empty-list). - */ - private function validateList(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + private function validateList(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { $baseType = strtolower($node->type->name); if (! \is_array($value) || (\count($value) > 0 && ! array_is_list($value))) { - return ErrorFactory::createError($context . ' must be a list, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be a list, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } $count = \count($value); @@ -495,8 +479,8 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte foreach ($sampleIndices as $k) { $v = $value[$k]; $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valueTypeNode, '') - : $registry->validate($v, $valueTypeNode, ''); + ? $this->validateObjectGeneric($v, $valueTypeNode, '', $isSensitive) + : $registry->validate($v, $valueTypeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); @@ -508,8 +492,8 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte foreach ($value as $k => $v) { $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valueTypeNode, '') - : $registry->validate($v, $valueTypeNode, ''); + ? $this->validateObjectGeneric($v, $valueTypeNode, '', $isSensitive) + : $registry->validate($v, $valueTypeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); @@ -519,15 +503,12 @@ private function validateList(mixed $value, GenericTypeNode $node, string $conte return null; } - /** - * Validates key-value array structures (e.g. array). - */ - private function validateArray(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + private function validateArray(mixed $value, GenericTypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { $baseType = strtolower($node->type->name); if (! \is_array($value) && ! ($value instanceof \Traversable)) { - return ErrorFactory::createError($context . ' must be of type ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } if (! \is_array($value)) { @@ -564,8 +545,8 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont foreach ($sampleKeys as $k) { $v = $value[$k]; $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '') - : $registry->validate($v, $valTypeNode, ''); + ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) + : $registry->validate($v, $valTypeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); @@ -577,8 +558,8 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont foreach ($value as $k => $v) { $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '') - : $registry->validate($v, $valTypeNode, ''); + ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) + : $registry->validate($v, $valTypeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . '[' . $k . ']' . $err->getMessage()); @@ -616,8 +597,8 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont if (! $valIsMixed) { $v = $value[$k]; $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '') - : $registry->validate($v, $valTypeNode, ''); + ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) + : $registry->validate($v, $valTypeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); @@ -638,8 +619,8 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont if (! $valIsMixed) { $err = $isComplexObjectGeneric - ? $this->validateObjectGeneric($v, $valTypeNode, '') - : $registry->validate($v, $valTypeNode, ''); + ? $this->validateObjectGeneric($v, $valTypeNode, '', $isSensitive) + : $registry->validate($v, $valTypeNode, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . "['" . $k . "']" . $err->getMessage()); @@ -651,18 +632,18 @@ private function validateArray(mixed $value, GenericTypeNode $node, string $cont return null; } - private function validateObjectGeneric(mixed $value, GenericTypeNode $node, string $context): ?ErrorMessage + private function validateObjectGeneric(mixed $value, GenericTypeNode $node, string $context, bool $isSensitive = false): ?ErrorMessage { if (! ClassNameValidator::isValid($node->type->name)) { return null; } if (! \is_object($value)) { - return ErrorFactory::createError($context . ' must be an object of type ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be an object of type ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } if (! is_a($value, $node->type->name)) { - return ErrorFactory::createError($context . ' must be an instance of ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be an instance of ' . $node->type->name . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return RuntimeTypeChecker::bindInstanceFromNode($value, $node, $context); diff --git a/src/Internal/Validator/IdentifierValidator.php b/src/Internal/Validator/IdentifierValidator.php index baef695..8ea6215 100644 --- a/src/Internal/Validator/IdentifierValidator.php +++ b/src/Internal/Validator/IdentifierValidator.php @@ -17,7 +17,7 @@ */ final class IdentifierValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { /** @var IdentifierTypeNode $identifierNode */ $identifierNode = $node; @@ -78,7 +78,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali }; if (! $ok) { - return ErrorFactory::createError($context . ' must be of type ' . $identifierNode->name . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type ' . $identifierNode->name . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } return null; diff --git a/src/Internal/Validator/IntersectionValidator.php b/src/Internal/Validator/IntersectionValidator.php index 531d8fa..def0a04 100644 --- a/src/Internal/Validator/IntersectionValidator.php +++ b/src/Internal/Validator/IntersectionValidator.php @@ -20,7 +20,7 @@ final class IntersectionValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { /** @var IntersectionTypeNode $intersectionNode */ $intersectionNode = $node; @@ -36,7 +36,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } foreach ($types as $type) { - $err = $registry->validate($value, $type, $context); + $err = $registry->validate($value, $type, $context, $isSensitive); if ($err !== null) { $msg = $err->getMessage(); @@ -52,7 +52,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } return ErrorFactory::createError( - $context . ' must be of type ' . $intersectionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given' + $context . ' must be of type ' . $intersectionNode . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given' ); } } diff --git a/src/Internal/Validator/NullableValidator.php b/src/Internal/Validator/NullableValidator.php index 941058e..f654ee1 100644 --- a/src/Internal/Validator/NullableValidator.php +++ b/src/Internal/Validator/NullableValidator.php @@ -13,13 +13,13 @@ */ final class NullableValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { if ($value === null) { return null; } /** @var NullableTypeNode $node */ - return $registry->validate($value, $node->type, $context); + return $registry->validate($value, $node->type, $context, $isSensitive); } } diff --git a/src/Internal/Validator/ObjectShapeValidator.php b/src/Internal/Validator/ObjectShapeValidator.php index a4863aa..06444a0 100644 --- a/src/Internal/Validator/ObjectShapeValidator.php +++ b/src/Internal/Validator/ObjectShapeValidator.php @@ -15,10 +15,10 @@ */ final class ObjectShapeValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { if (! \is_object($value)) { - return ErrorFactory::createError($context . ' must be of type object, ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type object, ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } /** @var ObjectShapeNode $shapeNode */ @@ -38,7 +38,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $propValue = $value->$propName; - $err = $registry->validate($propValue, $item->valueType, ''); + $err = $registry->validate($propValue, $item->valueType, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . "->{$propName}" . $err->getMessage()); } @@ -77,7 +77,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $propValue = $value->$propName; } - $err = $registry->validate($propValue, $item->valueType, ''); + $err = $registry->validate($propValue, $item->valueType, '', $isSensitive); if ($err !== null) { return ErrorFactory::createError($context . "->{$propName}" . $err->getMessage()); } diff --git a/src/Internal/Validator/TypeValidatorInterface.php b/src/Internal/Validator/TypeValidatorInterface.php index 74d282d..614d4f9 100644 --- a/src/Internal/Validator/TypeValidatorInterface.php +++ b/src/Internal/Validator/TypeValidatorInterface.php @@ -15,5 +15,11 @@ interface TypeValidatorInterface /** * Validates a value against an AST TypeNode and returns an ErrorMessage on failure or null on success. */ - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage; + public function validate( + mixed $value, + TypeNode $node, + string $context, + TypeValidatorRegistry $registry, + bool $isSensitive = false + ): ?ErrorMessage; } diff --git a/src/Internal/Validator/TypeValidatorRegistry.php b/src/Internal/Validator/TypeValidatorRegistry.php index a3efc2c..8fa29d4 100644 --- a/src/Internal/Validator/TypeValidatorRegistry.php +++ b/src/Internal/Validator/TypeValidatorRegistry.php @@ -78,13 +78,13 @@ public function __construct() /** * Validates a value against an AST TypeNode and returns an ErrorMessage on failure or null on success. */ - public function validate(mixed $value, TypeNode $node, string $context = ''): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context = '', bool $isSensitive = false): ?ErrorMessage { $validator = $this->validatorMap[$node::class] ?? null; if ($validator === null) { return null; } - return $validator->validate($value, $node, $context, $this); + return $validator->validate($value, $node, $context, $this, $isSensitive); } } diff --git a/src/Internal/Validator/UnionValidator.php b/src/Internal/Validator/UnionValidator.php index b868401..f7c2f15 100644 --- a/src/Internal/Validator/UnionValidator.php +++ b/src/Internal/Validator/UnionValidator.php @@ -23,7 +23,7 @@ */ final class UnionValidator implements TypeValidatorInterface { - public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry): ?ErrorMessage + public function validate(mixed $value, TypeNode $node, string $context, TypeValidatorRegistry $registry, bool $isSensitive = false): ?ErrorMessage { /** @var UnionTypeNode $unionNode */ $unionNode = $node; @@ -57,7 +57,7 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali $hasAnyDiscriminator = false; foreach ($unionNode->types as $type) { - $err = $registry->validate($value, $type, $context); + $err = $registry->validate($value, $type, $context, $isSensitive); if ($err === null) { return null; } @@ -93,14 +93,14 @@ public function validate(mixed $value, TypeNode $node, string $context, TypeVali } if ($hasAnyDiscriminator) { - return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } if (\count($deepErrors) > 0) { return $deepErrors[0]; } - return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value) . ' given'); + return ErrorFactory::createError($context . ' must be of type ' . $unionNode . ', ' . TypeFormatter::formatGivenValue($value, $isSensitive) . ' given'); } /** diff --git a/tests/TypeChecking/Boundaries/SensitiveParameterRedactionTest.php b/tests/TypeChecking/Boundaries/SensitiveParameterRedactionTest.php new file mode 100644 index 0000000..b60f941 --- /dev/null +++ b/tests/TypeChecking/Boundaries/SensitiveParameterRedactionTest.php @@ -0,0 +1,200 @@ +getMessage())->toContain("'correct_secret', string given"); + expect($e->getMessage())->not()->toContain($secretPassword); + expect($e->getMessage())->not()->toContain('[redacted]'); + } + + expect($failed)->toBeTrue(); + }); + + test('does NOT redact normal parameters without #[SensitiveParameter]', function () { + expect(fn () => testSensitiveLogin('', 'correct_secret')) + ->toThrow(TypeError::class, "Argument \$username must be of type non-empty-string, empty string ('') given") + ; + }); + + test('falls back to bare int given without leaking negative numbers', function () { + $secretPin = -9999; + + try { + testSensitiveMultiType($secretPin, ['secret_key' => 'valid']); + $failed = false; + } catch (TypeError $e) { + $failed = true; + + expect($e->getMessage())->toContain('Argument $pin must be of type positive-int, int given'); + expect($e->getMessage())->not()->toContain('-9999'); + } + + expect($failed)->toBeTrue(); + }); + + test('falls back to bare type when inner item violates sensitive array shape', function () { + try { + testSensitiveMultiType(1234, ['secret_key' => '']); + $failed = false; + } catch (TypeError $e) { + $failed = true; + + expect($e->getMessage())->toContain("Argument \$payload['secret_key'] must be of type non-empty-string, string given"); + expect($e->getMessage())->not()->toContain('[redacted]'); + } + + expect($failed)->toBeTrue(); + }); + }); + + describe('Class Methods & Constructors', function () { + test('falls back to bare type on sensitive class method parameters', function () { + $service = new SensitiveServiceFixture(); + $secretKey = ''; + + try { + $service->authenticate(42, $secretKey); + $failed = false; + } catch (TypeError $e) { + $failed = true; + + expect($e->getMessage())->toContain('Argument $apiKey must be of type non-empty-string, string given'); + } + + expect($failed)->toBeTrue(); + }); + + test('falls back to bare type on sensitive promoted constructor properties', function () { + $secretCode = -42; + + try { + new SensitiveCredentialsFixture('valid_token', $secretCode); + $failed = false; + } catch (TypeError $e) { + $failed = true; + + expect($e->getMessage())->toContain('Argument $secretCode must be of type positive-int, int given'); + expect($e->getMessage())->not()->toContain('-42'); + } + + expect($failed)->toBeTrue(); + }); + }); + + describe('Inherited Interface Contracts', function () { + test('inherits #[SensitiveParameter] redaction from interface contracts', function () { + $service = new InheritedSensitiveService(); + $leakedSecret = ''; + + try { + $service->process($leakedSecret); + $failed = false; + } catch (TypeError $e) { + $failed = true; + + expect($e->getMessage())->toContain('Argument $secret must be of type non-empty-string, string given'); + } + + expect($failed)->toBeTrue(); + }); + }); +});