diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 75187535..30cb3c45 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -1,5 +1,5 @@ parameters: - level: 6 + level: 7 phpVersion: 80300 paths: - src @@ -42,3 +42,22 @@ parameters: - identifier: argument.templateType paths: - src/Altair/Persistence/Cycle/CycleRepository.php + + # CollectionTrait is shared by array-backed collections (Map, Vector, Deque) and + # delegate-backed adapters (Set->Map, Stack->Vector, Queue->Deque). The adapters + # narrow $internal to a delegate object and override the entry points, but PHP's + # trait rules force them to keep the trait's `$internal = []` default, and PHPStan + # analyses the trait's array-backed storage per using-class. These are artefacts of + # that intentional shared-trait design, not reachable bugs. + - identifier: property.defaultValue + paths: + - src/Altair/Structure/Traits/CollectionTrait.php + - src/Altair/Structure/Set.php + - src/Altair/Structure/Stack.php + - src/Altair/Structure/Queue.php + - identifier: offsetAssign.dimType + paths: + - src/Altair/Structure/Traits/CollectionTrait.php + - + message: '#^Call to an undefined method [^:]+::adjustCapacity\(\)\.$#' + path: src/Altair/Structure/Traits/CollectionTrait.php diff --git a/src/Altair/AgentSpec/Generator/ApplicationManifestGenerator.php b/src/Altair/AgentSpec/Generator/ApplicationManifestGenerator.php index e0f8857d..14ea8a35 100644 --- a/src/Altair/AgentSpec/Generator/ApplicationManifestGenerator.php +++ b/src/Altair/AgentSpec/Generator/ApplicationManifestGenerator.php @@ -82,6 +82,7 @@ public function render(array $paths): string } /** + * @param class-string $fqcn * @return list */ private function matchedAttributes(string $fqcn): array diff --git a/src/Altair/AgentSpec/Reflection/AttributeScanner.php b/src/Altair/AgentSpec/Reflection/AttributeScanner.php index c6394444..4d8e21ac 100644 --- a/src/Altair/AgentSpec/Reflection/AttributeScanner.php +++ b/src/Altair/AgentSpec/Reflection/AttributeScanner.php @@ -59,6 +59,7 @@ public function scan(PackageDescriptor $package): array } /** + * @param class-string $fqcn * @return list */ private function collect(string $fqcn): array diff --git a/src/Altair/AgentSpec/Reflection/ClassNameExtractor.php b/src/Altair/AgentSpec/Reflection/ClassNameExtractor.php index ed6a8c83..e8f68c8f 100644 --- a/src/Altair/AgentSpec/Reflection/ClassNameExtractor.php +++ b/src/Altair/AgentSpec/Reflection/ClassNameExtractor.php @@ -29,7 +29,7 @@ public function extract(string $filePath): array return []; } - $tokens = PhpToken::tokenize($code); + $tokens = array_values(PhpToken::tokenize($code)); $namespace = ''; $classes = []; $tokenCount = \count($tokens); diff --git a/src/Altair/AgentSpec/Reflection/ConcreteClassScanner.php b/src/Altair/AgentSpec/Reflection/ConcreteClassScanner.php index 12019577..d3279413 100644 --- a/src/Altair/AgentSpec/Reflection/ConcreteClassScanner.php +++ b/src/Altair/AgentSpec/Reflection/ConcreteClassScanner.php @@ -67,6 +67,9 @@ private function isSkipped(string $file, string $sourceRoot): bool return \in_array($segment, self::SKIP_DIRECTORIES, true); } + /** + * @param class-string $fqcn + */ private function describeClass(string $fqcn, string $file, PackageDescriptor $package): ClassEntry { $reflection = new ReflectionClass($fqcn); diff --git a/src/Altair/AgentSpec/Reflection/ContractScanner.php b/src/Altair/AgentSpec/Reflection/ContractScanner.php index ab03ccfe..efa0e54b 100644 --- a/src/Altair/AgentSpec/Reflection/ContractScanner.php +++ b/src/Altair/AgentSpec/Reflection/ContractScanner.php @@ -55,6 +55,9 @@ public function scan(PackageDescriptor $package): array return $entries; } + /** + * @param class-string $fqcn + */ private function describeInterface(string $fqcn): ContractEntry { $reflection = new ReflectionClass($fqcn); diff --git a/src/Altair/AgentSpec/Reflection/PackageScanner.php b/src/Altair/AgentSpec/Reflection/PackageScanner.php index 9deca543..f81c5280 100644 --- a/src/Altair/AgentSpec/Reflection/PackageScanner.php +++ b/src/Altair/AgentSpec/Reflection/PackageScanner.php @@ -16,6 +16,7 @@ use Altair\AgentSpec\Model\PackageDescriptor; use FilesystemIterator; use Override; +use SplFileInfo; /** * Discovers sub-packages by looking for composer.json files inside the @@ -32,6 +33,10 @@ public function scan(string $sourceRoot, string $monorepoRoot, ?string $testsRoo $descriptors = []; foreach (new FilesystemIterator($sourceRoot, FilesystemIterator::SKIP_DOTS) as $entry) { + if (!$entry instanceof SplFileInfo) { + continue; + } + if (!$entry->isDir()) { continue; } diff --git a/src/Altair/Cache/CacheItem.php b/src/Altair/Cache/CacheItem.php index ee4044dd..2cf2df01 100644 --- a/src/Altair/Cache/CacheItem.php +++ b/src/Altair/Cache/CacheItem.php @@ -86,7 +86,7 @@ public function expiresAfter(int|DateInterval|null $time): static } if ($time instanceof DateInterval) { - $this->expirationTime = (int) DateTime::createFromFormat('Y-m-d H:i:s', date('Y-m-d H:i:s')) + $this->expirationTime = (int) (new DateTime()) ->add($time) ->format('U'); diff --git a/src/Altair/Cache/CacheItemPool.php b/src/Altair/Cache/CacheItemPool.php index cd202b68..9fa8b04c 100644 --- a/src/Altair/Cache/CacheItemPool.php +++ b/src/Altair/Cache/CacheItemPool.php @@ -239,7 +239,7 @@ public function commit(): bool } } else { // retry foreach (array_keys($values) as $id) { - $retry[$lifespan][] = $id; + $retry[$lifespan][] = (string) $id; } } } @@ -324,7 +324,7 @@ protected function ensureCommitDeferred(): void * * @return Generator */ - protected function createCacheItemsGenerator(array $items, array $keys): ?Generator + protected function createCacheItemsGenerator(array $items, array $keys): Generator { try { foreach ($items as $id => $value) { diff --git a/src/Altair/Cache/Storage/MemcachedCacheItemStorage.php b/src/Altair/Cache/Storage/MemcachedCacheItemStorage.php index a7343404..67f9a5b5 100644 --- a/src/Altair/Cache/Storage/MemcachedCacheItemStorage.php +++ b/src/Altair/Cache/Storage/MemcachedCacheItemStorage.php @@ -27,7 +27,8 @@ class MemcachedCacheItemStorage implements CacheItemStorageInterface */ public function __construct(Memcached $memcached) { - if (!(\extension_loaded('memcached') && version_compare(phpversion('memcached'), '2.2.0', '>='))) { + $version = phpversion('memcached'); + if (!\extension_loaded('memcached') || $version === false || !version_compare($version, '2.2.0', '>=')) { throw new CacheException('Memcached >= 2.2.0 is required.'); } diff --git a/src/Altair/Cli/Discovery/AttributeCommandDiscoverer.php b/src/Altair/Cli/Discovery/AttributeCommandDiscoverer.php index 4cd80a87..807b6827 100644 --- a/src/Altair/Cli/Discovery/AttributeCommandDiscoverer.php +++ b/src/Altair/Cli/Discovery/AttributeCommandDiscoverer.php @@ -97,7 +97,7 @@ private function extractClasses(string $filePath): array return []; } - $tokens = PhpToken::tokenize($code); + $tokens = array_values(PhpToken::tokenize($code)); $namespace = ''; $classes = []; diff --git a/src/Altair/Common/Support/Arr.php b/src/Altair/Common/Support/Arr.php index dbef8052..2637e744 100644 --- a/src/Altair/Common/Support/Arr.php +++ b/src/Altair/Common/Support/Arr.php @@ -106,16 +106,24 @@ public static function getValue(mixed $array, $key, mixed $default = null) $array = static::getValue($array, $keyPart); } + if ($lastKey === null) { + return $array; + } + $key = $lastKey; } + if ($key instanceof Closure) { + return $key($array, $default); + } + if (\is_array($array) && (isset($array[$key]) || \array_key_exists($key, $array))) { return $array[$key]; } - if (($pos = strrpos((string) $key, '.')) !== false) { - $array = static::getValue($array, substr((string) $key, 0, $pos), $default); - $key = substr((string) $key, $pos + 1); + if (($pos = strrpos($key, '.')) !== false) { + $array = static::getValue($array, substr($key, 0, $pos), $default); + $key = substr($key, $pos + 1); } if (\is_object($array)) { @@ -497,21 +505,30 @@ public static function multisort(array &$array, $key, $direction = SORT_ASC, $so throw new InvalidArgumentException('The length of $sortFlag parameter must be the same as that of $keys.'); } - $args = []; + // The first sort column is passed as the leading positional argument so the + // call statically satisfies array_multisort()'s "first argument is an array" + // contract; the remaining direction/flag/column triples are spread after it. + $firstColumn = null; + $rest = []; foreach ($keys as $i => $k) { - $flag = $sortFlag[$i]; - $args[] = static::getColumn($array, $k); - $args[] = $direction[$i]; - $args[] = $flag; + $column = static::getColumn($array, $k); + if ($firstColumn === null) { + $firstColumn = $column; + } else { + $rest[] = $column; + } + + $rest[] = $direction[$i]; + $rest[] = $sortFlag[$i]; } // This fix is used for cases when main sorting specified by columns has equal values // Without it it will lead to Fatal Error: Nesting level too deep - recursive dependency? - $args[] = range(1, \count($array)); - $args[] = SORT_ASC; - $args[] = SORT_NUMERIC; - $args[] = &$array; - array_multisort(...$args); + $rest[] = range(1, \count($array)); + $rest[] = SORT_ASC; + $rest[] = SORT_NUMERIC; + $rest[] = &$array; + array_multisort($firstColumn, ...$rest); } /** diff --git a/src/Altair/Common/Support/Inflector.php b/src/Altair/Common/Support/Inflector.php index ce82c865..5b2eaf04 100644 --- a/src/Altair/Common/Support/Inflector.php +++ b/src/Altair/Common/Support/Inflector.php @@ -35,10 +35,14 @@ public function __construct(protected Transliterator $transliterator, protected public function slug(string $value, ?string $replacement = null, $lowercase = true): string { $replacement ??= '-'; - $value = $this->transliterator->transliterate($value); - $value = preg_replace('/[^a-zA-Z0-9=\s—–-]+/u', '', $value); - $value = preg_replace('/[=\s—–-]+/u', $replacement, (string) $value); - $value = trim((string) $value, $replacement); + $transliterated = $this->transliterator->transliterate($value); + if ($transliterated !== false) { + $value = $transliterated; + } + + $value = (string) preg_replace('/[^a-zA-Z0-9=\s—–-]+/u', '', $value); + $value = (string) preg_replace('/[=\s—–-]+/u', $replacement, $value); + $value = trim($value, $replacement); return $lowercase ? strtolower($value) : $value; } @@ -49,7 +53,7 @@ public function slug(string $value, ?string $replacement = null, $lowercase = tr * For example, 'post-tag' is converted to 'PostTag'. * * @param string $id the id to be converted - * @param string $separator the character used to separate the words in the id + * @param non-empty-string $separator the character used to separate the words in the id */ public function idToCamel(string $id, string $separator = '-'): string { diff --git a/src/Altair/Common/Support/Str.php b/src/Altair/Common/Support/Str.php index 53c0197d..2be367e9 100644 --- a/src/Altair/Common/Support/Str.php +++ b/src/Altair/Common/Support/Str.php @@ -63,6 +63,9 @@ public function truncate(string $value, int $length, string $suffix = '...', str public function truncateWords(string $value, int $count, string $suffix = '...'): string { $words = preg_split('/(\s+)/u', trim($value), -1, PREG_SPLIT_DELIM_CAPTURE); + if ($words === false) { + return $value; + } return \count($words) / 2 > $count ? implode('', \array_slice($words, 0, ($count * 2) - 1)) . $suffix @@ -123,7 +126,9 @@ public function endsWith( */ public function countWords(string $value): int { - return \count(preg_split('/\s+/u', $value, -1, PREG_SPLIT_NO_EMPTY)); + $words = preg_split('/\s+/u', $value, -1, PREG_SPLIT_NO_EMPTY); + + return $words === false ? 0 : \count($words); } /** diff --git a/src/Altair/Container/Builder/ExecutableBuilder.php b/src/Altair/Container/Builder/ExecutableBuilder.php index 2f4409ba..268f8880 100644 --- a/src/Altair/Container/Builder/ExecutableBuilder.php +++ b/src/Altair/Container/Builder/ExecutableBuilder.php @@ -114,7 +114,12 @@ protected function buildExecutableStructureFromClassMethodCallable(string $class if ($relativeStaticMethodStartPos === 0) { $childReflection = $this->container->getReflector()->getClass($class); - $class = $childReflection->getParentClass()->name; + $parentReflection = $childReflection->getParentClass(); + if ($parentReflection === false) { + throw new InjectionException(\sprintf('Class "%s" has no parent class.', $class)); + } + + $class = $parentReflection->name; $method = substr($method, 8); } diff --git a/src/Altair/Container/Executable.php b/src/Altair/Container/Executable.php index 66fbb345..2e08791b 100644 --- a/src/Altair/Container/Executable.php +++ b/src/Altair/Container/Executable.php @@ -86,7 +86,7 @@ protected function setMethodCallable(ReflectionMethod $reflection, mixed $object } /** - * @param array $args + * @param array $args */ protected function invokeClosure(ReflectionFunction $reflection, array $args): mixed { diff --git a/src/Altair/Container/Reflection/StandardReflection.php b/src/Altair/Container/Reflection/StandardReflection.php index 0ac3e75f..e6a93a26 100644 --- a/src/Altair/Container/Reflection/StandardReflection.php +++ b/src/Altair/Container/Reflection/StandardReflection.php @@ -31,6 +31,10 @@ class StandardReflection implements ReflectionInterface #[Override] public function getClass(string $class): ReflectionClass { + if (!class_exists($class) && !interface_exists($class)) { + throw new ReflectionException(\sprintf('Class "%s" does not exist.', $class)); + } + return new ReflectionClass($class); } diff --git a/src/Altair/Cookie/Support/CookieStr.php b/src/Altair/Cookie/Support/CookieStr.php index bcdb22a2..d9a9fbc1 100644 --- a/src/Altair/Cookie/Support/CookieStr.php +++ b/src/Altair/Cookie/Support/CookieStr.php @@ -20,7 +20,12 @@ class CookieStr */ public function split(string $value): array { - return array_filter(preg_split('@\s*[;]\s*@', $value)); + $parts = preg_split('@\s*[;]\s*@', $value); + if ($parts === false) { + return []; + } + + return array_values(array_filter($parts)); } /** diff --git a/src/Altair/Courier/Contracts/InMemoryCommandLocatorServiceInterface.php b/src/Altair/Courier/Contracts/InMemoryCommandLocatorServiceInterface.php index 747334cc..26ec07bf 100644 --- a/src/Altair/Courier/Contracts/InMemoryCommandLocatorServiceInterface.php +++ b/src/Altair/Courier/Contracts/InMemoryCommandLocatorServiceInterface.php @@ -25,7 +25,7 @@ public function withMap(MessageCommandMap $map): InMemoryCommandLocatorServiceIn /** * Adds a new message to command mapping. * - * + * @param class-string $commandName */ public function add(string $messageName, string $commandName): InMemoryCommandLocatorServiceInterface; } diff --git a/src/Altair/Courier/Service/InMemoryCommandLocatorService.php b/src/Altair/Courier/Service/InMemoryCommandLocatorService.php index 64415373..acaa8305 100644 --- a/src/Altair/Courier/Service/InMemoryCommandLocatorService.php +++ b/src/Altair/Courier/Service/InMemoryCommandLocatorService.php @@ -43,6 +43,8 @@ public function withMap(MessageCommandMap $map): InMemoryCommandLocatorServiceIn /** * @inheritDoc + * + * @param class-string $commandName */ #[Override] public function add(string $messageName, string $commandName): InMemoryCommandLocatorServiceInterface diff --git a/src/Altair/Courier/Strategy/CommandRunnerMiddlewareStrategy.php b/src/Altair/Courier/Strategy/CommandRunnerMiddlewareStrategy.php index a9ee6a46..f5d419e2 100644 --- a/src/Altair/Courier/Strategy/CommandRunnerMiddlewareStrategy.php +++ b/src/Altair/Courier/Strategy/CommandRunnerMiddlewareStrategy.php @@ -49,6 +49,10 @@ public function __construct(?array $middlewares = null, protected ?MiddlewareRes public function withMiddlewares(array $middlewares): CommandRunnerStrategyInterface { foreach ($middlewares as $middleware) { + if ($middleware instanceof CommandMiddlewareInterface) { + continue; + } + if (!is_subclass_of($middleware, CommandMiddlewareInterface::class)) { throw new InvalidCommandMiddlewareException( \sprintf( @@ -93,11 +97,30 @@ protected function call(int $index): Closure $middleware = $this->middlewares[$index]; if ($this->resolver instanceof MiddlewareResolverInterface) { - $middleware = \call_user_func($this->resolver, $middleware); + $resolved = \call_user_func($this->resolver, $middleware); + if (!$resolved instanceof CommandMiddlewareInterface) { + throw new InvalidCommandMiddlewareException( + \sprintf( + 'Resolved command middleware must implement %s', + CommandMiddlewareInterface::class + ) + ); + } + + $middleware = $resolved; $this->middlewares[$index] = $middleware; } - return function ($message) use ($middleware, $index): void { + if (!$middleware instanceof CommandMiddlewareInterface) { + throw new InvalidCommandMiddlewareException( + \sprintf( + 'Command middleware must be resolved to an instance of %s before execution', + CommandMiddlewareInterface::class + ) + ); + } + + return function (CommandMessageInterface $message) use ($middleware, $index): void { $middleware->handle($message, $this->call($index + 1)); }; } diff --git a/src/Altair/Events/Cli/ShowCommand.php b/src/Altair/Events/Cli/ShowCommand.php index 9142ab3a..635443e1 100644 --- a/src/Altair/Events/Cli/ShowCommand.php +++ b/src/Altair/Events/Cli/ShowCommand.php @@ -62,8 +62,11 @@ public function __invoke( echo $this->renderer->eventDetailHuman($event); if ($snapshot !== null) { - echo "snapshot:\n"; - echo " " . str_replace("\n", "\n ", json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)), "\n"; + $encoded = json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); + if ($encoded !== false) { + echo "snapshot:\n"; + echo " " . str_replace("\n", "\n ", $encoded), "\n"; + } } return 0; diff --git a/src/Altair/Filesystem/Filesystem.php b/src/Altair/Filesystem/Filesystem.php index ec0621b8..da2c6b2e 100644 --- a/src/Altair/Filesystem/Filesystem.php +++ b/src/Altair/Filesystem/Filesystem.php @@ -35,11 +35,20 @@ class Filesystem */ public function get(string $path, bool $lock = false): string { - if ($this->isFile($path)) { - return $lock ? $this->getShared($path) : file_get_contents($path); + if (!$this->isFile($path)) { + throw new FileNotFoundException('File does not exist at path ' . $path); } - throw new FileNotFoundException('File does not exist at path ' . $path); + if ($lock) { + return $this->getShared($path); + } + + $contents = file_get_contents($path); + if ($contents === false) { + throw new FileNotFoundException('Unable to read file at path ' . $path); + } + + return $contents; } /** @@ -55,7 +64,13 @@ public function getShared(string $path): string try { if (flock($handle, LOCK_SH)) { clearstatcache(true, $path); - $contents = fread($handle, $this->getFileSize($path) ?: 1); + $size = $this->getFileSize($path); + $length = $size === false ? 1 : max(1, $size); + $read = fread($handle, $length); + if ($read !== false) { + $contents = $read; + } + flock($handle, LOCK_UN); } } finally { @@ -75,7 +90,9 @@ public function readLines(string $path): array { // auto_detect_line_endings was deprecated in PHP 8.1; PHP now handles CRLF/CR // line endings natively for file()/fgets() without configuration. - return file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + $lines = file($path, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES); + + return $lines === false ? [] : $lines; } /** @@ -285,6 +302,10 @@ public function copyDirectory($directory, string $destination, $options = null): $items = new FilesystemIterator($directory, $options); foreach ($items as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + // As we spin through items, we will check to see if the current file is actually // a directory or a file. When it is actually a directory we will need to call // back into this function recursively to keep copying these nested folders. @@ -318,6 +339,10 @@ public function deleteDirectory($directory, $preserve = false): bool $items = new FilesystemIterator($directory); foreach ($items as $item) { + if (!$item instanceof SplFileInfo) { + continue; + } + // If the item is a directory, we can just recurse into the function and // delete that sub-directory otherwise we'll just delete the file and // keep iterating through each file until the directory is cleaned. @@ -366,12 +391,15 @@ public function getFileName(string $path): string /** * Get the MD5 hash of the file at the given path. - * - * @param string $path */ - public function getFileHash($path): string + public function getFileHash(string $path): string { - return md5_file($path); + $hash = md5_file($path); + if ($hash === false) { + throw new FileNotFoundException('Unable to hash file at path ' . $path); + } + + return $hash; } /** @@ -403,7 +431,12 @@ public function getFileExtension(string $path): string */ public function getType(string $path): string { - return filetype($path); + $type = filetype($path); + if ($type === false) { + throw new FileNotFoundException('Unable to determine type of path ' . $path); + } + + return $type; } /** @@ -414,7 +447,12 @@ public function getType(string $path): string */ public function getFileMimeType(string $path): string|false { - return finfo_file(finfo_open(FILEINFO_MIME_TYPE), $path); + $finfo = finfo_open(FILEINFO_MIME_TYPE); + if ($finfo === false) { + return false; + } + + return finfo_file($finfo, $path); } /** diff --git a/src/Altair/Happen/EventDispatcher.php b/src/Altair/Happen/EventDispatcher.php index f98ddce5..395a86a1 100644 --- a/src/Altair/Happen/EventDispatcher.php +++ b/src/Altair/Happen/EventDispatcher.php @@ -21,7 +21,7 @@ class EventDispatcher implements EventDispatcherInterface { /** - * @var array>> keeps reference of the registered listeners. + * @var array>> keeps reference of the registered listeners. */ protected $listeners = []; @@ -63,15 +63,10 @@ public function addListenerProvider(ListenerProviderInterface $provider): EventD public function addSubscriber(EventSubscriberInterface $subscriber): EventDispatcherInterface { foreach ($subscriber->getSubscribedEvents() as $name => $params) { - if (\is_string($params)) { - $this->addListener($name, [$subscriber, $params]); - } elseif (\is_string($params[0])) { - [$method, $priority] = $params + [null, 0]; - $this->addListener($name, [$subscriber, $method], $priority ?? 0); - } else { - foreach ($params as $listener) { - [$method, $priority] = $listener + [null, 0]; - $this->addListener($name, [$subscriber, $method], $priority ?? 0); + foreach ($this->normalizeSubscribedEvent($params) as [$method, $priority]) { + $listener = [$subscriber, $method]; + if (\is_callable($listener)) { + $this->addListener($name, $listener, $priority); } } } @@ -208,18 +203,52 @@ public function removeAllListeners(string $name): EventDispatcherInterface public function removeSubscriber(EventSubscriberInterface $subscriber): EventDispatcherInterface { foreach ($subscriber->getSubscribedEvents() as $name => $params) { - if (\is_array($params) && \is_array($params[0])) { - foreach ($params as $listener) { - $this->removeListener($name, [$subscriber, $listener[0]]); + foreach ($this->normalizeSubscribedEvent($params) as [$method]) { + $listener = [$subscriber, $method]; + if (\is_callable($listener)) { + $this->removeListener($name, $listener); } - } else { - $this->removeListener($name, [$subscriber, \is_string($params) ? $params : $params[0]]); } } return $this; } + /** + * Normalizes a single entry of {@see EventSubscriberInterface::getSubscribedEvents()} + * into a list of method/priority pairs. + * + * @param string|array{0: string, 1?: int}|list $params + * + * @return list + */ + protected function normalizeSubscribedEvent(string|array $params): array + { + if (\is_string($params)) { + return [[$params, EventDispatcherInterface::NORMAL_PRIORITY]]; + } + + $first = $params[0]; + + if (\is_string($first)) { + $priority = $params[1] ?? EventDispatcherInterface::NORMAL_PRIORITY; + + return [[$first, \is_int($priority) ? $priority : EventDispatcherInterface::NORMAL_PRIORITY]]; + } + + $normalized = []; + foreach ($params as $listener) { + if (!\is_array($listener)) { + continue; + } + + $priority = $listener[1] ?? EventDispatcherInterface::NORMAL_PRIORITY; + $normalized[] = [$listener[0], \is_int($priority) ? $priority : EventDispatcherInterface::NORMAL_PRIORITY]; + } + + return $normalized; + } + /** * Invokes all listeners of an event. * diff --git a/src/Altair/Http/Base/InputParser.php b/src/Altair/Http/Base/InputParser.php index c2f40538..73868604 100644 --- a/src/Altair/Http/Base/InputParser.php +++ b/src/Altair/Http/Base/InputParser.php @@ -51,9 +51,21 @@ protected function getParsedBody(ServerRequestInterface $request): array return []; } - return $body instanceof JsonSerializable - ? $body->jsonSerialize() - // if parsed body is an object but doesn't implements JsonSerializable use json parsing instead - : (\is_object($body) ? json_decode(json_encode($body), true) : $body); + if ($body instanceof JsonSerializable) { + return (array) $body->jsonSerialize(); + } + + // if parsed body is an object but doesn't implement JsonSerializable use json parsing instead + if (\is_object($body)) { + $encoded = json_encode($body); + + if ($encoded === false) { + return []; + } + + return (array) json_decode($encoded, true); + } + + return $body; } } diff --git a/src/Altair/Http/Contracts/TokenConfigurationInterface.php b/src/Altair/Http/Contracts/TokenConfigurationInterface.php index 5d595e99..cd3ebe7a 100644 --- a/src/Altair/Http/Contracts/TokenConfigurationInterface.php +++ b/src/Altair/Http/Contracts/TokenConfigurationInterface.php @@ -17,6 +17,8 @@ interface TokenConfigurationInterface { /** * The public key used to sign the JWT token. + * + * @return non-empty-string */ public function getPublicKey(): string; @@ -29,6 +31,8 @@ public function getSigner(): Signer; /** * The stable issuer identifier (`iss` claim) for tokens minted and validated by this service. + * + * @return non-empty-string */ public function getIssuer(): string; diff --git a/src/Altair/Http/Formatter/JsonFormatter.php b/src/Altair/Http/Formatter/JsonFormatter.php index aef93aab..95879780 100644 --- a/src/Altair/Http/Formatter/JsonFormatter.php +++ b/src/Altair/Http/Formatter/JsonFormatter.php @@ -17,15 +17,12 @@ class JsonFormatter implements OutputFormatterInterface { - /** - * @var int - */ - protected $options = 0; + protected int $options = 0; /** - * @var int + * @var int<1, max> */ - protected $depth = 512; + protected int $depth = 512; /** * @inheritDoc @@ -52,6 +49,6 @@ public function type(): string #[Override] public function body(PayloadInterface $payload): string { - return json_encode($payload->getOutput(), $this->options, $this->depth); + return json_encode($payload->getOutput(), $this->options | JSON_THROW_ON_ERROR, $this->depth); } } diff --git a/src/Altair/Http/Formatter/PhpViewFormatter.php b/src/Altair/Http/Formatter/PhpViewFormatter.php index 988ea2d2..c83bdbb8 100644 --- a/src/Altair/Http/Formatter/PhpViewFormatter.php +++ b/src/Altair/Http/Formatter/PhpViewFormatter.php @@ -105,7 +105,9 @@ protected function renderPhpFile(string $file, array $params = []): string try { require($file); - return ob_get_clean(); + $content = ob_get_clean(); + + return $content === false ? '' : $content; } catch (Throwable $throwable) { while (ob_get_level() > $level) { if (!@ob_end_clean()) { diff --git a/src/Altair/Http/Jwt/LcobucciTokenGenerator.php b/src/Altair/Http/Jwt/LcobucciTokenGenerator.php index 0f6c0a3d..15f2e0b3 100644 --- a/src/Altair/Http/Jwt/LcobucciTokenGenerator.php +++ b/src/Altair/Http/Jwt/LcobucciTokenGenerator.php @@ -65,6 +65,10 @@ public function generate(array $claims = []): string } foreach ($claims as $name => $value) { + if ($name === '') { + continue; + } + // Builder is immutable in v5: each withClaim() returns a new instance. $builder = $builder->withClaim($name, $value); } diff --git a/src/Altair/Http/Jwt/LcobucciTokenParser.php b/src/Altair/Http/Jwt/LcobucciTokenParser.php index ec220a62..a0e37de8 100644 --- a/src/Altair/Http/Jwt/LcobucciTokenParser.php +++ b/src/Altair/Http/Jwt/LcobucciTokenParser.php @@ -59,6 +59,10 @@ public function __construct( #[Override] public function parse(string $token): TokenInterface { + if ($token === '') { + throw new InvalidTokenException('Could not parse the authorization token.'); + } + $configuration = $this->buildConfiguration(); $parsed = $this->parseToken($configuration, $token); @@ -101,6 +105,8 @@ private function buildConfiguration(): Configuration } /** + * @param non-empty-string $token + * * @throws InvalidTokenException */ private function parseToken(Configuration $configuration, string $token): UnencryptedToken diff --git a/src/Altair/Http/Middleware/DigestAuthenticationMiddleware.php b/src/Altair/Http/Middleware/DigestAuthenticationMiddleware.php index 75cd7854..6c81007f 100644 --- a/src/Altair/Http/Middleware/DigestAuthenticationMiddleware.php +++ b/src/Altair/Http/Middleware/DigestAuthenticationMiddleware.php @@ -102,7 +102,7 @@ private function parseAuthorizationHeader(ServerRequestInterface $request): ?arr $expected = array_flip($parts); foreach ($matches as $match) { - $data[$match[1]] = $match[3] ?? $match[4]; + $data[$match[1]] = $match[3] ?? $match[4] ?? ''; unset($expected[$match[1]]); } diff --git a/src/Altair/Http/Middleware/DispatcherMiddleware.php b/src/Altair/Http/Middleware/DispatcherMiddleware.php index 6e27540a..850508f8 100644 --- a/src/Altair/Http/Middleware/DispatcherMiddleware.php +++ b/src/Altair/Http/Middleware/DispatcherMiddleware.php @@ -56,7 +56,7 @@ private function dispatch(Dispatcher $dispatcher, string $method, string $path): $status = array_shift($route); return match ($status) { - Dispatcher::FOUND => $route, + Dispatcher::FOUND => $this->resolveFoundRoute($route), Dispatcher::METHOD_NOT_ALLOWED => throw new HttpMethodNotAllowedException( array_shift($route), \sprintf("Cannot access resource '%s' using method '%s'", $path, $method), @@ -65,4 +65,27 @@ private function dispatch(Dispatcher $dispatcher, string $method, string $path): default => throw new HttpNotFoundException($path), }; } + + /** + * @param array $route the dispatch result with the leading status already shifted off + * + * @return array{0: Action, 1: array} + */ + private function resolveFoundRoute(array $route): array + { + [$action, $arguments] = $route; + + if (!$action instanceof Action) { + throw new HttpNotFoundException('The matched route handler is not a valid action.'); + } + + $variables = []; + if (\is_array($arguments)) { + foreach ($arguments as $key => $value) { + $variables[(string) $key] = $value; + } + } + + return [$action, $variables]; + } } diff --git a/src/Altair/Http/Middleware/FormContentMiddleware.php b/src/Altair/Http/Middleware/FormContentMiddleware.php index 88737cd1..b556a934 100644 --- a/src/Altair/Http/Middleware/FormContentMiddleware.php +++ b/src/Altair/Http/Middleware/FormContentMiddleware.php @@ -29,6 +29,11 @@ protected function parse(string $body): array { parse_str($body, $parsed); - return $parsed; + $normalized = []; + foreach ($parsed as $key => $value) { + $normalized[(string) $key] = $value; + } + + return $normalized; } } diff --git a/src/Altair/Http/Middleware/JsonContentMiddleware.php b/src/Altair/Http/Middleware/JsonContentMiddleware.php index 21d1f3d3..7a8f2e3b 100644 --- a/src/Altair/Http/Middleware/JsonContentMiddleware.php +++ b/src/Altair/Http/Middleware/JsonContentMiddleware.php @@ -12,16 +12,31 @@ namespace Altair\Http\Middleware; use Altair\Http\Exception\HttpBadRequestException; +use Altair\Http\Exception\InvalidArgumentException; use JsonException; use Override; class JsonContentMiddleware extends AbstractContentHandlerMiddleware { + /** + * @var int<1, max> + */ + private readonly int $maxDepth; + + /** + * @param int<1, max> $maxDepth + */ public function __construct( private readonly bool $associative = true, - private readonly int $maxDepth = 512, + int $maxDepth = 512, private readonly int $flags = 0, - ) {} + ) { + if ($maxDepth < 1) { + throw new InvalidArgumentException('The maximum decoding depth must be at least 1.'); + } + + $this->maxDepth = $maxDepth; + } #[Override] protected function contentTypes(): array diff --git a/src/Altair/Http/Middleware/SessionHeadersMiddleware.php b/src/Altair/Http/Middleware/SessionHeadersMiddleware.php index 0bb780da..321265cd 100644 --- a/src/Altair/Http/Middleware/SessionHeadersMiddleware.php +++ b/src/Altair/Http/Middleware/SessionHeadersMiddleware.php @@ -31,7 +31,7 @@ public function __construct( public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface { $prevName = session_name(); - $prevCookie = $this->cookieManager->getFromRequest($request, $prevName); + $prevCookie = $this->cookieManager->getFromRequest($request, $prevName === false ? '' : $prevName); $prevId = $prevCookie->getValue(); if ($prevId !== null) { session_id($prevId); diff --git a/src/Altair/Http/Responder/FormattedResponder.php b/src/Altair/Http/Responder/FormattedResponder.php index dec5783b..9b07c6c8 100644 --- a/src/Altair/Http/Responder/FormattedResponder.php +++ b/src/Altair/Http/Responder/FormattedResponder.php @@ -71,7 +71,7 @@ public function withFormatter(string $formatter, float $priority): FormattedResp } /** - * @param array, float> $formatters + * @param array $formatters * * @return array, float> */ @@ -137,7 +137,7 @@ protected function format( * * */ - protected function getFormatter(ServerRequestInterface $request): object + protected function getFormatter(ServerRequestInterface $request): OutputFormatterInterface { $accept = $request->getHeaderLine('Accept'); $priorities = $this->priorities(); @@ -151,6 +151,18 @@ protected function getFormatter(ServerRequestInterface $request): object $formatter = array_shift($priorities); } - return $this->resolve($formatter); + if ($formatter === null) { + throw new InvalidFormatterException('No output formatter is available to satisfy the request.'); + } + + $resolved = $this->resolve($formatter); + + if (!$resolved instanceof OutputFormatterInterface) { + throw new InvalidFormatterException( + \sprintf("Resolved formatter '%s' is not a valid output formatter.", $formatter), + ); + } + + return $resolved; } } diff --git a/src/Altair/Http/Rule/RequestPathRule.php b/src/Altair/Http/Rule/RequestPathRule.php index bc585f92..9f0f8fb9 100644 --- a/src/Altair/Http/Rule/RequestPathRule.php +++ b/src/Altair/Http/Rule/RequestPathRule.php @@ -28,8 +28,7 @@ class RequestPathRule implements HttpAuthRuleInterface /** * Create a new rule instance * - * @param string[] $options - * + * @param array> $options */ public function __construct(array $options = []) { diff --git a/src/Altair/Http/Support/BodyCredentialsExtractor.php b/src/Altair/Http/Support/BodyCredentialsExtractor.php index 66864837..625cdbe1 100644 --- a/src/Altair/Http/Support/BodyCredentialsExtractor.php +++ b/src/Altair/Http/Support/BodyCredentialsExtractor.php @@ -19,11 +19,8 @@ class BodyCredentialsExtractor implements CredentialsExtractorInterface { /** * BodyCredentialsBuilder constructor. - * - * @param string $identifier - * @param string $password */ - public function __construct(private $identifier = 'username', private $password = 'password') {} + public function __construct(private readonly string $identifier = 'username', private readonly string $password = 'password') {} /** * @inheritDoc @@ -34,6 +31,10 @@ public function extract(ServerRequestInterface $request): ?array { $body = $request->getParsedBody(); + if (!\is_array($body)) { + return null; + } + if (empty($body[$this->identifier]) || empty($body[$this->password])) { return null; } diff --git a/src/Altair/Http/Support/DefaultErrorHandler.php b/src/Altair/Http/Support/DefaultErrorHandler.php index cb78d486..d1932021 100644 --- a/src/Altair/Http/Support/DefaultErrorHandler.php +++ b/src/Altair/Http/Support/DefaultErrorHandler.php @@ -13,6 +13,7 @@ use Altair\Http\Contracts\ErrorHandlerInterface; use Altair\Http\Contracts\MiddlewareInterface; +use Altair\Http\Exception\RuntimeException; use GdImage; use Laminas\Diactoros\Response; use Override; @@ -61,10 +62,10 @@ public function __invoke(ServerRequestInterface $request, ResponseInterface $res $message = $error !== null ? $error->getMessage() : ''; $response = (new Response('php://memory', $response->getStatusCode())); - foreach ($this->handlers as $types) { + foreach ($this->handlers as $handler => $types) { foreach ($types as $type) { - if (stripos($accept, (string) $type) !== false) { - \call_user_func([$this, $type], $response->getStatusCode(), $message); + if (stripos($accept, $type) !== false) { + $this->{$handler}($response->getStatusCode(), $message); return $response->withHeader('Content-Type', $type); } @@ -76,13 +77,22 @@ public function __invoke(ServerRequestInterface $request, ResponseInterface $res return $response->withHeader('Content-Type', 'text/html'); } + /** + * Output the error as plain text. + */ + protected function plain(int $statusCode, string $message): void + { + echo \sprintf('Error %d', $statusCode); + + if ($message !== '') { + echo "\n" . $message; + } + } + /** * Output the error as svg image. - * - * @param int $statusCode - * @param string $message */ - protected function svg($statusCode, $message): void + protected function svg(int $statusCode, string $message): void { echo << @@ -95,11 +105,8 @@ protected function svg($statusCode, $message): void /** * Output the error as html. - * - * @param int $statusCode - * @param string $message */ - protected function html($statusCode, $message): void + protected function html(int $statusCode, string $message): void { echo << @@ -120,14 +127,11 @@ protected function html($statusCode, $message): void /** * Output the error as json. - * - * @param int $statusCode - * @param string $message */ - protected function json($statusCode, $message): void + protected function json(int $statusCode, string $message): void { $output = ['error' => $statusCode]; - if (!empty($message)) { + if ($message !== '' && $message !== '0') { $output['message'] = $message; } @@ -136,11 +140,8 @@ protected function json($statusCode, $message): void /** * Output the error as xml. - * - * @param int $statusCode - * @param string $message */ - protected function xml($statusCode, $message): void + protected function xml(int $statusCode, string $message): void { echo << @@ -153,11 +154,8 @@ protected function xml($statusCode, $message): void /** * Output the error as jpeg. - * - * @param int $statusCode - * @param string $message */ - protected function jpeg($statusCode, $message): void + protected function jpeg(int $statusCode, string $message): void { $image = $this->createImage($statusCode, $message); imagejpeg($image); @@ -165,11 +163,8 @@ protected function jpeg($statusCode, $message): void /** * Output the error as gif. - * - * @param int $statusCode - * @param string $message */ - protected function gif($statusCode, $message): void + protected function gif(int $statusCode, string $message): void { $image = $this->createImage($statusCode, $message); imagegif($image); @@ -177,11 +172,8 @@ protected function gif($statusCode, $message): void /** * Output the error as png. - * - * @param int $statusCode - * @param string $message */ - protected function png($statusCode, $message): void + protected function png(int $statusCode, string $message): void { $image = $this->createImage($statusCode, $message); imagepng($image); @@ -190,16 +182,23 @@ protected function png($statusCode, $message): void /** * Creates a image resource with the error text. * - * @param int $statusCode - * @param string $message - * - * @return GdImage|false + * @throws RuntimeException when the GD image cannot be allocated */ - protected function createImage($statusCode, $message): GdImage|false + protected function createImage(int $statusCode, string $message): GdImage { $size = 200; $image = imagecreatetruecolor($size, $size); + + if ($image === false) { + throw new RuntimeException('Unable to allocate a GD image for the error output.'); + } + $textColor = imagecolorallocate($image, 255, 255, 255); + + if ($textColor === false) { + throw new RuntimeException('Unable to allocate a color for the error image.'); + } + imagestring($image, 5, 10, 10, 'Error ' . $statusCode, $textColor); foreach (str_split($message, \intval($size / 10)) as $line => $text) { imagestring($image, 5, 10, ($line * 18) + 28, $text, $textColor); diff --git a/src/Altair/Http/Support/MimeType.php b/src/Altair/Http/Support/MimeType.php index 6a9c7596..b3f6a537 100644 --- a/src/Altair/Http/Support/MimeType.php +++ b/src/Altair/Http/Support/MimeType.php @@ -1034,9 +1034,15 @@ public function getFromFileExtension(string $file): string if (\function_exists('finfo_open') && $file->isFile()) { $path = $file->getPath(); $fileInfo = finfo_open(FILEINFO_MIME); + + if ($fileInfo === false) { + return static::DEFAULT_MIME_TYPE; + } + $mimeType = finfo_file($fileInfo, $path); finfo_close($fileInfo); - return $mimeType; + + return $mimeType === false ? static::DEFAULT_MIME_TYPE : $mimeType; } return static::DEFAULT_MIME_TYPE; diff --git a/src/Altair/Http/Support/TokenConfiguration.php b/src/Altair/Http/Support/TokenConfiguration.php index 7ed9c3db..90ca0d54 100644 --- a/src/Altair/Http/Support/TokenConfiguration.php +++ b/src/Altair/Http/Support/TokenConfiguration.php @@ -12,6 +12,7 @@ namespace Altair\Http\Support; use Altair\Http\Contracts\TokenConfigurationInterface; +use Altair\Http\Exception\InvalidArgumentException; use Lcobucci\JWT\Signer; use Override; @@ -21,6 +22,9 @@ /** * TokenGeneratorConfiguration constructor. + * + * @param non-empty-string $publicKey + * @param non-empty-string $issuer */ public function __construct( private string $publicKey, @@ -31,6 +35,14 @@ public function __construct( private ?string $privateKey = null, private ?string $audience = null ) { + if ($publicKey === '') { + throw new InvalidArgumentException('The public key must be a non-empty string.'); + } + + if ($issuer === '') { + throw new InvalidArgumentException('The issuer must be a non-empty string.'); + } + $this->timestamp = $timestamp ?: time(); } diff --git a/src/Altair/Messaging/Discovery/AttributeHandlerDiscoverer.php b/src/Altair/Messaging/Discovery/AttributeHandlerDiscoverer.php index 50876e36..0a4bb155 100644 --- a/src/Altair/Messaging/Discovery/AttributeHandlerDiscoverer.php +++ b/src/Altair/Messaging/Discovery/AttributeHandlerDiscoverer.php @@ -107,7 +107,7 @@ private function extractClasses(string $filePath): array return []; } - $tokens = PhpToken::tokenize($code); + $tokens = array_values(PhpToken::tokenize($code)); $namespace = ''; $classes = []; diff --git a/src/Altair/Middleware/Resolver/MiddlewareResolver.php b/src/Altair/Middleware/Resolver/MiddlewareResolver.php index 0e906191..7f3bf84a 100644 --- a/src/Altair/Middleware/Resolver/MiddlewareResolver.php +++ b/src/Altair/Middleware/Resolver/MiddlewareResolver.php @@ -15,6 +15,7 @@ use Altair\Container\Exception\InjectionException; use Altair\Middleware\Contracts\MiddlewareInterface; use Altair\Middleware\Contracts\MiddlewareResolverInterface; +use InvalidArgumentException; use Override; use ReflectionException; @@ -26,17 +27,26 @@ class MiddlewareResolver implements MiddlewareResolverInterface public function __construct(protected Container $container) {} /** - * @param mixed $entry * @throws InjectionException * @throws ReflectionException + * @throws InvalidArgumentException */ #[Override] - public function __invoke($entry): MiddlewareInterface + public function __invoke(mixed $entry): MiddlewareInterface { - if (\is_object($entry)) { + if ($entry instanceof MiddlewareInterface) { return $entry; } - return $this->container->make($entry); + if (\is_string($entry)) { + $resolved = $this->container->make($entry); + if ($resolved instanceof MiddlewareInterface) { + return $resolved; + } + } + + throw new InvalidArgumentException( + \sprintf('Unable to resolve middleware entry to an instance of %s.', MiddlewareInterface::class) + ); } } diff --git a/src/Altair/Persistence/Configuration/DatabaseConnectionFactory.php b/src/Altair/Persistence/Configuration/DatabaseConnectionFactory.php index 926699f4..777a6de8 100644 --- a/src/Altair/Persistence/Configuration/DatabaseConnectionFactory.php +++ b/src/Altair/Persistence/Configuration/DatabaseConnectionFactory.php @@ -53,21 +53,21 @@ private function buildDriverConfig(DatabaseSettings $settings): object DatabaseSettings::DRIVER_POSTGRES => new PostgresDriverConfig( connection: new PostgresTcpConnection( database: $settings->database, - host: $settings->host, - port: $settings->port, - user: $settings->user, - password: $settings->password, + host: $settings->tcpHost(), + port: $settings->tcpPort(), + user: $settings->tcpUser(), + password: $settings->tcpPassword(), ), queryCache: true, ), DatabaseSettings::DRIVER_MYSQL => new MySQLDriverConfig( connection: new MySQLTcpConnection( database: $settings->database, - host: $settings->host, - port: $settings->port, - charset: $settings->charset, - user: $settings->user, - password: $settings->password, + host: $settings->tcpHost(), + port: $settings->tcpPort(), + charset: $settings->tcpCharset(), + user: $settings->tcpUser(), + password: $settings->tcpPassword(), ), queryCache: true, ), @@ -80,10 +80,10 @@ private function buildDriverConfig(DatabaseSettings $settings): object DatabaseSettings::DRIVER_SQLSERVER => new SQLServerDriverConfig( connection: new SQLServerTcpConnection( database: $settings->database, - host: $settings->host, - port: $settings->port, - user: $settings->user, - password: $settings->password, + host: $settings->tcpHost(), + port: $settings->tcpPort(), + user: $settings->tcpUser(), + password: $settings->tcpPassword(), ), queryCache: true, ), diff --git a/src/Altair/Persistence/Configuration/DatabaseSettings.php b/src/Altair/Persistence/Configuration/DatabaseSettings.php index f4b0751e..65bdf7e0 100644 --- a/src/Altair/Persistence/Configuration/DatabaseSettings.php +++ b/src/Altair/Persistence/Configuration/DatabaseSettings.php @@ -37,7 +37,8 @@ ]; /** - * @param array $options Driver-specific PDO options. + * @param non-empty-string $database + * @param array $options Driver-specific PDO options. */ public function __construct( public string $driver, @@ -60,6 +61,16 @@ public function __construct( if ($database === '') { throw new InvalidConfigurationException('DB_DATABASE must not be empty.'); } + + if ($driver !== self::DRIVER_SQLITE) { + if ($host === '') { + throw new InvalidConfigurationException('DB_HOST must not be empty.'); + } + + if ($port < 1) { + throw new InvalidConfigurationException('DB_PORT must be a positive integer.'); + } + } } /** @@ -75,6 +86,9 @@ public static function fromEnv(array $env): self } $database = (string) ($env['DB_DATABASE'] ?? ''); + if ($database === '') { + throw new InvalidConfigurationException('DB_DATABASE must not be empty.'); + } return new self( driver: $driver, @@ -92,6 +106,58 @@ public function isSqlite(): bool return $this->driver === self::DRIVER_SQLITE; } + /** + * Host narrowed for TCP drivers (validated non-empty in the constructor). + * + * @return non-empty-string + */ + public function tcpHost(): string + { + if ($this->host === '') { + throw new InvalidConfigurationException('DB_HOST must not be empty.'); + } + + return $this->host; + } + + /** + * Port narrowed for TCP drivers (validated positive in the constructor). + * + * @return int<1, max> + */ + public function tcpPort(): int + { + if ($this->port < 1) { + throw new InvalidConfigurationException('DB_PORT must be a positive integer.'); + } + + return $this->port; + } + + /** + * @return non-empty-string|null + */ + public function tcpUser(): ?string + { + return $this->user === '' ? null : $this->user; + } + + /** + * @return non-empty-string|null + */ + public function tcpPassword(): ?string + { + return $this->password === '' ? null : $this->password; + } + + /** + * @return non-empty-string|null + */ + public function tcpCharset(): ?string + { + return $this->charset === '' ? null : $this->charset; + } + private static function defaultPort(string $driver): int { return match ($driver) { diff --git a/src/Altair/Sanitation/Collection/FilterCollection.php b/src/Altair/Sanitation/Collection/FilterCollection.php index 4df2fd1f..6fb02e8a 100644 --- a/src/Altair/Sanitation/Collection/FilterCollection.php +++ b/src/Altair/Sanitation/Collection/FilterCollection.php @@ -71,7 +71,7 @@ protected function filterKey(mixed $key): void protected function parseFilters(mixed $filters): void { if (\is_string($filters)) { - if (!\in_array(FilterInterface::class, class_implements($filters), false)) { + if (!$this->implementsFilterInterface($filters)) { throw new InvalidArgumentException( \sprintf( '"%s" does not implement %s.', @@ -87,7 +87,7 @@ protected function parseFilters(mixed $filters): void } $class = $filter['class'] ?? null; - if ($class === null || !\in_array(FilterInterface::class, class_implements($class), false)) { + if (!\is_string($class) || !$this->implementsFilterInterface($class)) { throw new InvalidArgumentException( \sprintf( 'A definition of a filter as array must have a "class" key and must implement %s.', @@ -98,4 +98,14 @@ protected function parseFilters(mixed $filters): void } } } + + /** + * Resolves whether the given class name implements the filter contract. + */ + private function implementsFilterInterface(string $class): bool + { + $implemented = class_implements($class); + + return $implemented !== false && \in_array(FilterInterface::class, $implemented, false); + } } diff --git a/src/Altair/Sanitation/Filter/DateTimeFilter.php b/src/Altair/Sanitation/Filter/DateTimeFilter.php index 4c1d4e6b..cf81f325 100644 --- a/src/Altair/Sanitation/Filter/DateTimeFilter.php +++ b/src/Altair/Sanitation/Filter/DateTimeFilter.php @@ -52,6 +52,8 @@ protected function buildDateTime(mixed $value): ?DateTime return null; } + $value = (string) $value; + if (trim($value) === '') { return null; } diff --git a/src/Altair/Sanitation/Filter/LowerCaseFilter.php b/src/Altair/Sanitation/Filter/LowerCaseFilter.php index 9471a753..7f42525a 100644 --- a/src/Altair/Sanitation/Filter/LowerCaseFilter.php +++ b/src/Altair/Sanitation/Filter/LowerCaseFilter.php @@ -30,6 +30,8 @@ public function parse($value): ?string return null; } + $value = (string) $value; + return $this->firstOnly ? $this->getFirstToLower($value) : strtolower($value); } diff --git a/src/Altair/Sanitation/Filter/UpperCaseFilter.php b/src/Altair/Sanitation/Filter/UpperCaseFilter.php index c5314fbe..a1723a25 100644 --- a/src/Altair/Sanitation/Filter/UpperCaseFilter.php +++ b/src/Altair/Sanitation/Filter/UpperCaseFilter.php @@ -30,6 +30,8 @@ public function parse($value): ?string return null; } + $value = (string) $value; + return $this->firstOnly ? $this->getFirstToUpper($value) : strtoupper($value); } diff --git a/src/Altair/Sanitation/Resolver/FilterResolver.php b/src/Altair/Sanitation/Resolver/FilterResolver.php index e8392a53..7256f9da 100644 --- a/src/Altair/Sanitation/Resolver/FilterResolver.php +++ b/src/Altair/Sanitation/Resolver/FilterResolver.php @@ -16,6 +16,7 @@ use Altair\Container\Exception\InjectionException; use Altair\Sanitation\Contracts\FilterInterface; use Altair\Sanitation\Contracts\ResolverInterface; +use Altair\Sanitation\Exception\InvalidArgumentException; use Override; use ReflectionException; @@ -27,14 +28,13 @@ class FilterResolver implements ResolverInterface public function __construct(protected Container $container) {} /** - * @param mixed $entry * @throws InjectionException * @throws ReflectionException */ #[Override] - public function __invoke($entry): FilterInterface + public function __invoke(mixed $entry): FilterInterface { - if (\is_object($entry)) { // string + if ($entry instanceof FilterInterface) { return $entry; } @@ -44,6 +44,20 @@ public function __invoke($entry): FilterInterface $entry = $entry['class']; // force error if key is not configured } // else is a string - return $this->container->make($entry, new Definition($arguments)); + if (!\is_string($entry)) { + throw new InvalidArgumentException( + \sprintf('A filter entry must resolve to a class-string or %s instance.', FilterInterface::class) + ); + } + + $filter = $this->container->make($entry, new Definition($arguments)); + + if (!$filter instanceof FilterInterface) { + throw new InvalidArgumentException( + \sprintf('"%s" does not implement %s.', $entry, FilterInterface::class) + ); + } + + return $filter; } } diff --git a/src/Altair/Scaffold/Emitter/OpenApiEmitter.php b/src/Altair/Scaffold/Emitter/OpenApiEmitter.php index 39137489..6a79dd2e 100644 --- a/src/Altair/Scaffold/Emitter/OpenApiEmitter.php +++ b/src/Altair/Scaffold/Emitter/OpenApiEmitter.php @@ -91,8 +91,12 @@ private function renderOperation(Spec $spec): array } /** - * @param list $outputs - * @return array + * Keys are HTTP status codes (or the literal `default`). PHP coerces the + * numeric-string status keys back to integers, so the honest key type is + * `int|string`; the emitted YAML renders both forms identically. + * + * @param list $outputs + * @return array */ private function renderResponses(array $outputs): array { diff --git a/src/Altair/Scaffold/Journal/Differ/FileDiffer.php b/src/Altair/Scaffold/Journal/Differ/FileDiffer.php index 1fb5ed59..017c46f0 100644 --- a/src/Altair/Scaffold/Journal/Differ/FileDiffer.php +++ b/src/Altair/Scaffold/Journal/Differ/FileDiffer.php @@ -45,9 +45,15 @@ public function diff(string $before, string $after, string $beforeLabel = 'befor } /** - * @param list $a - * @param list $b - * @return list> + * Builds the LCS length table. Cell `[i][j]` is the LCS length of the + * first `i` lines of `$a` and first `j` lines of `$b`; row/column 0 is the + * empty-prefix base case (all zeroes). The table is a dense `(m+1)x(n+1)` + * int grid, so it is modelled as an int-keyed 2D array (not a `list`) to + * reflect that arbitrary in-range integer offsets are valid reads. + * + * @param list $a + * @param list $b + * @return array> */ private function lcsTable(array $a, array $b): array { @@ -67,9 +73,9 @@ private function lcsTable(array $a, array $b): array } /** - * @param list> $t - * @param list $a - * @param list $b + * @param array> $t + * @param list $a + * @param list $b * * @return list */ @@ -84,7 +90,7 @@ private function backtrack(array $t, array $a, array $b, int $i, int $j): array } elseif ($j > 0 && ($i === 0 || $t[$i][$j - 1] >= $t[$i - 1][$j])) { $ops[] = ['op' => '+', 'a' => $i, 'b' => $j, 'line' => $b[$j - 1]]; $j--; - } else { + } elseif ($i > 0) { $ops[] = ['op' => '-', 'a' => $i, 'b' => $j, 'line' => $a[$i - 1]]; $i--; } diff --git a/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php b/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php index f6d63d96..54a06dec 100644 --- a/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php +++ b/src/Altair/Scaffold/Sdk/Model/OpenApiParser.php @@ -358,7 +358,7 @@ private function refName(string $ref): string } /** - * @param list $types + * @param array $types */ private function firstNonNull(array $types): ?string { diff --git a/src/Altair/Scaffold/Spec/Parser.php b/src/Altair/Scaffold/Spec/Parser.php index 19b6bb45..cc5194b8 100644 --- a/src/Altair/Scaffold/Spec/Parser.php +++ b/src/Altair/Scaffold/Spec/Parser.php @@ -170,7 +170,7 @@ private function parseEndpoint(array $data): EndpointSpec } /** - * @param array $data + * @param array $data * @return list */ private function parseInputs(array $data): array diff --git a/src/Altair/Security/Exception/InvalidArgumentException.php b/src/Altair/Security/Exception/InvalidArgumentException.php new file mode 100644 index 00000000..f6b0b9c6 --- /dev/null +++ b/src/Altair/Security/Exception/InvalidArgumentException.php @@ -0,0 +1,14 @@ +algorithm, $this->key, (string) $this->salt, $this->iterations, $this->length, true); + $length = max(0, $this->length); + $iterations = max(1, $this->iterations); + + return hash_pbkdf2($this->algorithm, $this->key, (string) $this->salt, $iterations, $length, true); } } diff --git a/src/Altair/Security/Support/Salt.php b/src/Altair/Security/Support/Salt.php index 72258605..ee615bf4 100644 --- a/src/Altair/Security/Support/Salt.php +++ b/src/Altair/Security/Support/Salt.php @@ -11,6 +11,7 @@ namespace Altair\Security\Support; +use Altair\Security\Exception\InvalidArgumentException; use Exception; class Salt @@ -20,6 +21,12 @@ class Salt */ public function generate(int $length = 32): string { + if ($length < 1) { + throw new InvalidArgumentException( + \sprintf('Salt length must be a positive integer, "%d" given.', $length) + ); + } + return substr(strtr(base64_encode(random_bytes($length)), '+/=', '_-.'), 0, $length); } } diff --git a/src/Altair/Session/Handler/MongoSessionHandler.php b/src/Altair/Session/Handler/MongoSessionHandler.php index 570fb273..a2bbe2df 100644 --- a/src/Altair/Session/Handler/MongoSessionHandler.php +++ b/src/Altair/Session/Handler/MongoSessionHandler.php @@ -59,12 +59,17 @@ public function close() public function read($sessionId) { $data = $this->collection->findOne( - ['_id' => $sessionId, 'session_lifetime' => ['$gte' => $this->createUTCDateTime()]] + ['_id' => $sessionId, 'session_lifetime' => ['$gte' => $this->createUTCDateTime()]], + ['typeMap' => ['root' => 'array', 'document' => 'array']] ); - return null === $data || !isset($data['content']) - ? '' - : $data['content']->getData(); + if (!\is_array($data) || !isset($data['content'])) { + return ''; + } + + $content = $data['content']; + + return $content instanceof Binary ? $content->getData() : ''; } /** diff --git a/src/Altair/Session/SessionBlock.php b/src/Altair/Session/SessionBlock.php index 0a4185ce..1fdacc22 100644 --- a/src/Altair/Session/SessionBlock.php +++ b/src/Altair/Session/SessionBlock.php @@ -108,7 +108,7 @@ public function getFlash(string $key, $default = null, bool $delete = false) #[Override] public function getAllFlashes($delete = false): array { - $counters = $this->get(SessionBlockInterface::FLASH_KEY, []); + $counters = $this->getFlashCounters(); $flashes = []; foreach (array_keys($counters) as $key) { if ($this->has($key)) { @@ -187,7 +187,7 @@ public function removeFlash($key) #[Override] public function removeAllFlashes(): void { - $counters = $this->get(SessionBlockInterface::FLASH_KEY, []); + $counters = $this->getFlashCounters(); foreach (array_keys($counters) as $key) { $this->remove($key); } @@ -204,6 +204,30 @@ public function hasFlash($key): bool return $this->getFlash($key) !== null; } + /** + * Reads the flash counter map from the session, normalizing it to a + * map of string flash keys to their integer counters. Non-conforming + * entries that may exist in raw session data are discarded. + * + * @return array + */ + protected function getFlashCounters(): array + { + $raw = $this->get(SessionBlockInterface::FLASH_KEY, []); + if (!\is_array($raw)) { + return []; + } + + $counters = []; + foreach ($raw as $key => $count) { + if (\is_int($count)) { + $counters[(string) $key] = $count; + } + } + + return $counters; + } + /** * Loads the segment only if the session has already been started, or if * a session is available (in which case it resumes the session first). diff --git a/src/Altair/Session/SessionManager.php b/src/Altair/Session/SessionManager.php index 46872aef..83640723 100644 --- a/src/Altair/Session/SessionManager.php +++ b/src/Altair/Session/SessionManager.php @@ -74,7 +74,9 @@ public function __construct( #[Override] public function getId(): string { - return session_id(); + $id = session_id(); + + return $id === false ? '' : $id; } /** @@ -114,7 +116,9 @@ public function getSessionBlock(string $name): SessionBlockInterface #[Override] public function getName(): string { - return session_name(); + $name = session_name(); + + return $name === false ? '' : $name; } /** @@ -132,7 +136,9 @@ public function setName(string $name): void #[Override] public function getSavePath(): string { - return session_save_path(); + $path = session_save_path(); + + return $path === false ? '' : $path; } /** diff --git a/src/Altair/Structure/Map.php b/src/Altair/Structure/Map.php index ef4e2104..bfcf4ea2 100644 --- a/src/Altair/Structure/Map.php +++ b/src/Altair/Structure/Map.php @@ -207,7 +207,7 @@ public function put($key, $value): MapInterface /** * {@inheritDoc} * - * @param array|Traversable $values + * @param array|Traversable $values * * @return MapInterface */ @@ -384,11 +384,11 @@ public function first(): PairInterface #[Override] public function last(): PairInterface { - if ($this->isEmpty()) { + if ($this->internal === []) { throw new UnderflowException('Map is empty'); } - return end($this->internal); + return $this->internal[array_key_last($this->internal)]; } /** diff --git a/src/Altair/Structure/Pair.php b/src/Altair/Structure/Pair.php index 91264053..a92bc87a 100644 --- a/src/Altair/Structure/Pair.php +++ b/src/Altair/Structure/Pair.php @@ -45,17 +45,14 @@ public function __construct( ) {} /** - * This allows unset($pair->key) to not completely remove the property, - * but be set to null instead. - * - * - * @return mixed|null + * Resolves reads of $key/$value after they have been unset, returning null + * rather than triggering an "undefined property" error. The property is not + * re-initialised, so its declared TKey/TValue type is never violated; every + * subsequent read routes back through this accessor and yields null. */ public function __get(mixed $name): mixed { if ($name === 'key' || $name === 'value') { - $this->$name = null; - return null; } diff --git a/src/Altair/Structure/PriorityNode.php b/src/Altair/Structure/PriorityNode.php index 9a2b098f..335dc347 100644 --- a/src/Altair/Structure/PriorityNode.php +++ b/src/Altair/Structure/PriorityNode.php @@ -33,7 +33,10 @@ class PriorityNode implements PriorityNodeInterface public function __construct(public mixed $value, public int $priority, public int $stamp) {} /** - * Allows unset($node->value) to soft-null the payload rather than remove it. + * Resolves reads of $value after it has been unset, returning null rather + * than triggering an "undefined property" error. The property is not + * re-initialised, so its declared TValue type is never violated; every + * subsequent read routes back through this accessor and yields null. * * priority and stamp are required ordering keys (always int) and are not * accessible through this magic getter. @@ -41,8 +44,6 @@ public function __construct(public mixed $value, public int $priority, public in public function __get(string $name): mixed { if ($name === 'value') { - $this->value = null; - return null; } diff --git a/src/Altair/Structure/PriorityQueue.php b/src/Altair/Structure/PriorityQueue.php index 44c9c6b0..0c21c2de 100644 --- a/src/Altair/Structure/PriorityQueue.php +++ b/src/Altair/Structure/PriorityQueue.php @@ -12,7 +12,6 @@ namespace Altair\Structure; use Altair\Structure\Contracts\CollectionInterface; -use Altair\Structure\Contracts\PriorityNodeInterface; use Altair\Structure\Traits\CollectionTrait; use Altair\Structure\Traits\SquaredCapacityTrait; use Generator; @@ -273,9 +272,9 @@ protected function siftUp(int $leaf): void /** * Set Root. * - * @param PriorityNodeInterface $node + * @param PriorityNode $node */ - protected function setRoot(PriorityNodeInterface $node): void + protected function setRoot(PriorityNode $node): void { $this->heap[0] = $node; } @@ -285,7 +284,7 @@ protected function setRoot(PriorityNodeInterface $node): void * * @return PriorityNode */ - protected function getRoot(): PriorityNodeInterface + protected function getRoot(): PriorityNode { return $this->heap[0]; } diff --git a/src/Altair/Structure/Queue.php b/src/Altair/Structure/Queue.php index 1960b430..b323a892 100644 --- a/src/Altair/Structure/Queue.php +++ b/src/Altair/Structure/Queue.php @@ -50,8 +50,6 @@ class Queue implements IteratorAggregate, ArrayAccess, QueueInterface, CapacityI * with a Deque in the constructor before any method is invoked. * * @var Deque - * - * @phpstan-ignore property.defaultValue */ protected $internal = []; diff --git a/src/Altair/Structure/Set.php b/src/Altair/Structure/Set.php index e807c814..8513efe2 100644 --- a/src/Altair/Structure/Set.php +++ b/src/Altair/Structure/Set.php @@ -58,8 +58,6 @@ class Set implements IteratorAggregate, ArrayAccess, SetInterface, CapacityInter * replaced with a Map in the constructor before any method is invoked. * * @var Map - * - * @phpstan-ignore property.defaultValue */ protected $internal = []; diff --git a/src/Altair/Structure/Stack.php b/src/Altair/Structure/Stack.php index 6732f134..7c00f099 100644 --- a/src/Altair/Structure/Stack.php +++ b/src/Altair/Structure/Stack.php @@ -51,8 +51,6 @@ class Stack implements IteratorAggregate, ArrayAccess, StackInterface, CapacityI * with a Vector in the constructor before any method is invoked. * * @var Vector - * - * @phpstan-ignore property.defaultValue */ protected $internal = []; diff --git a/src/Altair/Structure/Traits/CapacityTrait.php b/src/Altair/Structure/Traits/CapacityTrait.php index 7e0de6d9..ae46eccc 100644 --- a/src/Altair/Structure/Traits/CapacityTrait.php +++ b/src/Altair/Structure/Traits/CapacityTrait.php @@ -62,7 +62,7 @@ protected function adjustCapacity(): void // Automatically truncate the allocated buffer when the size of the // structure drops low enough. if ($size < $this->capacity / 4) { - $this->capacity = max(CapacityInterface::MIN_CAPACITY, $this->capacity / 2); + $this->capacity = max(CapacityInterface::MIN_CAPACITY, intdiv($this->capacity, 2)); } elseif ($size >= $this->capacity) { // Also check if we should increase capacity when the size changes. $this->increaseCapacity(); } diff --git a/src/Altair/Structure/Traits/CollectionTrait.php b/src/Altair/Structure/Traits/CollectionTrait.php index 29dee873..a83ea6c4 100644 --- a/src/Altair/Structure/Traits/CollectionTrait.php +++ b/src/Altair/Structure/Traits/CollectionTrait.php @@ -13,6 +13,10 @@ use Altair\Structure\Contracts\CapacityInterface; use Altair\Structure\Contracts\CollectionInterface; + +use const JSON_THROW_ON_ERROR; + +use JsonException; use JsonSerializable; use ReturnTypeWillChange; use Traversable; @@ -36,8 +40,6 @@ trait CollectionTrait * evaluates the default per using-class, so the check is suppressed here. * * @var array - * - * @phpstan-ignore property.defaultValue */ protected $internal = []; @@ -129,11 +131,13 @@ public function count(): int * @param int $options Bitmask of the different options of the json_encode function. * @param int $depth Sets the maximum depth. Must be greater than 0. * - * @return string a JSON encoded string of the items or false on failure + * @throws JsonException when the items cannot be encoded to JSON + * + * @return string a JSON encoded string of the items */ public function toJson(int $options = 0, $depth = 512): string { - return json_encode($this->toArray(), $options, $depth); + return json_encode($this->toArray(), $options | JSON_THROW_ON_ERROR, max(1, $depth)); } /** @@ -153,16 +157,14 @@ abstract public function toArray(): array; */ protected function pushAll(mixed $values): void { + // Adapter collections (Set, Stack, Queue) narrow $internal to a delegate + // object and override the entry points, so this array-backed pushAll is + // only ever reached on the array-backed collections (Map, Vector, Deque). foreach ($values as $value) { - // Adapter collections (Set, Stack, Queue) narrow $internal to a - // delegate object and never call pushAll, so the array-append and - // adjustCapacity() call below are unreachable in those contexts. - // @phpstan-ignore offsetAssign.dimType $this->internal[] = $value; } if ($this instanceof CapacityInterface) { - // @phpstan-ignore method.notFound $this->adjustCapacity(); } } diff --git a/src/Altair/Structure/Traits/SequenceTrait.php b/src/Altair/Structure/Traits/SequenceTrait.php index f2ac0eed..c99381d7 100644 --- a/src/Altair/Structure/Traits/SequenceTrait.php +++ b/src/Altair/Structure/Traits/SequenceTrait.php @@ -166,11 +166,11 @@ public function join(?string $glue = null): string */ public function last(): mixed { - if ($this->isEmpty()) { + if ($this->internal === []) { throw new UnderflowException('Is empty'); } - return end($this->internal); + return $this->internal[array_key_last($this->internal)]; } /** diff --git a/src/Altair/Structure/Vector.php b/src/Altair/Structure/Vector.php index 1042d0e4..c18a0ed8 100644 --- a/src/Altair/Structure/Vector.php +++ b/src/Altair/Structure/Vector.php @@ -65,7 +65,7 @@ protected function adjustCapacity(): void // Automatically truncate the allocated buffer when the size of the // structure drops low enough. if ($size < $this->capacity / 4) { - $this->capacity = max(VectorInterface::MIN_VECTOR_CAPACITY, $this->capacity / 2); + $this->capacity = max(VectorInterface::MIN_VECTOR_CAPACITY, intdiv($this->capacity, 2)); } elseif ($size >= $this->capacity) { // Also check if we should increase capacity when the size changes. $this->increaseCapacity(); diff --git a/src/Altair/TestReporter/Resolver/SourceUnderTestResolver.php b/src/Altair/TestReporter/Resolver/SourceUnderTestResolver.php index 64f6aab0..f5961c4a 100644 --- a/src/Altair/TestReporter/Resolver/SourceUnderTestResolver.php +++ b/src/Altair/TestReporter/Resolver/SourceUnderTestResolver.php @@ -193,13 +193,12 @@ private function fromNamespaceHeuristic(ReflectionClass $reflection, string $tes return []; } + /** + * @param class-string $className + */ private function locateClass(string $className, string $preferredMethod): ?SourceLocation { - try { - $reflection = new ReflectionClass($className); - } catch (Throwable) { - return null; - } + $reflection = new ReflectionClass($className); $file = $reflection->getFileName(); if ($file === false) { diff --git a/src/Altair/Validation/Collection/RuleCollection.php b/src/Altair/Validation/Collection/RuleCollection.php index 3a0bdf6e..1ba53109 100644 --- a/src/Altair/Validation/Collection/RuleCollection.php +++ b/src/Altair/Validation/Collection/RuleCollection.php @@ -71,7 +71,7 @@ protected function filterKey(mixed $key): void protected function filterRules(mixed $rules): void { if (\is_string($rules)) { - if (!\in_array(RuleInterface::class, class_implements($rules), false)) { + if (!$this->implementsRuleInterface($rules)) { throw new InvalidArgumentException( \sprintf( '"%s" does not implement %s.', @@ -87,7 +87,7 @@ protected function filterRules(mixed $rules): void } $class = $rule['class'] ?? null; - if ($class === null || !\in_array(RuleInterface::class, class_implements($class), false)) { + if (!\is_string($class) || !$this->implementsRuleInterface($class)) { throw new InvalidArgumentException( \sprintf( 'A definition of a rule as array must have a "class" key and must implement %s.', @@ -98,4 +98,14 @@ protected function filterRules(mixed $rules): void } } } + + /** + * Resolves whether the given class name implements the rule contract. + */ + private function implementsRuleInterface(string $class): bool + { + $implemented = class_implements($class); + + return $implemented !== false && \in_array(RuleInterface::class, $implemented, false); + } } diff --git a/src/Altair/Validation/Resolver/RuleResolver.php b/src/Altair/Validation/Resolver/RuleResolver.php index 8a676eed..2d6a2988 100644 --- a/src/Altair/Validation/Resolver/RuleResolver.php +++ b/src/Altair/Validation/Resolver/RuleResolver.php @@ -16,6 +16,7 @@ use Altair\Container\Exception\InjectionException; use Altair\Validation\Contracts\ResolverInterface; use Altair\Validation\Contracts\RuleInterface; +use Altair\Validation\Exception\InvalidArgumentException; use Override; use ReflectionException; @@ -27,14 +28,13 @@ class RuleResolver implements ResolverInterface public function __construct(protected Container $container) {} /** - * @param mixed $entry * @throws InjectionException * @throws ReflectionException */ #[Override] - public function __invoke($entry): RuleInterface + public function __invoke(mixed $entry): RuleInterface { - if (\is_object($entry)) { // string + if ($entry instanceof RuleInterface) { return $entry; } @@ -44,6 +44,20 @@ public function __invoke($entry): RuleInterface $entry = $entry['class']; // force error if key is not configured } // else is a string - return $this->container->make($entry, new Definition($arguments)); + if (!\is_string($entry)) { + throw new InvalidArgumentException( + \sprintf('A rule entry must resolve to a class-string or %s instance.', RuleInterface::class) + ); + } + + $rule = $this->container->make($entry, new Definition($arguments)); + + if (!$rule instanceof RuleInterface) { + throw new InvalidArgumentException( + \sprintf('"%s" does not implement %s.', $entry, RuleInterface::class) + ); + } + + return $rule; } } diff --git a/src/Altair/Validation/Rule/DateTimeRule.php b/src/Altair/Validation/Rule/DateTimeRule.php index e34ece01..5c81b196 100644 --- a/src/Altair/Validation/Rule/DateTimeRule.php +++ b/src/Altair/Validation/Rule/DateTimeRule.php @@ -26,7 +26,13 @@ public function assert(mixed $value): bool return (bool) $value; } - if (!\is_scalar($value) || trim($value) === '') { + if (!\is_scalar($value)) { + return false; + } + + $value = (string) $value; + + if (trim($value) === '') { return false; } diff --git a/src/Altair/Validation/Rule/IbanRule.php b/src/Altair/Validation/Rule/IbanRule.php index 85d3a363..fa85004a 100644 --- a/src/Altair/Validation/Rule/IbanRule.php +++ b/src/Altair/Validation/Rule/IbanRule.php @@ -97,7 +97,7 @@ public function assert(mixed $value): bool return false; } - $value = $this->sanitize($value); + $value = $this->sanitize((string) $value); if (mb_strlen($value) < 15) { return false; diff --git a/src/Altair/Validation/Rule/InRule.php b/src/Altair/Validation/Rule/InRule.php index 35dfd1f7..7dd84189 100644 --- a/src/Altair/Validation/Rule/InRule.php +++ b/src/Altair/Validation/Rule/InRule.php @@ -37,9 +37,11 @@ public function assert(mixed $value): bool } $value = (string) $value; + $encoding = mb_detect_encoding($value) ?: null; + return $this->strict - ? false !== mb_strpos((string) $this->haystack, $value, 0, mb_detect_encoding($value)) - : false !== mb_stripos((string) $this->haystack, $value, 0, mb_detect_encoding($value)); + ? false !== mb_strpos((string) $this->haystack, $value, 0, $encoding) + : false !== mb_stripos((string) $this->haystack, $value, 0, $encoding); } #[Override] diff --git a/src/Altair/Validation/Rule/IpRule.php b/src/Altair/Validation/Rule/IpRule.php index 57ea55bb..6e5921b0 100644 --- a/src/Altair/Validation/Rule/IpRule.php +++ b/src/Altair/Validation/Rule/IpRule.php @@ -113,7 +113,8 @@ protected function parseRangeUsingCidr(string $value, array $range): array throw new InvalidArgumentException('Invalid network mask.'); } - $range['mask'] = \sprintf('%032b', ip2long(long2ip(~((2 ** (32 - $prefix)) - 1)))); + $netmask = ~((1 << (32 - $prefix)) - 1) & 0xFFFFFFFF; + $range['mask'] = \sprintf('%032b', $netmask); return $range; } @@ -154,9 +155,15 @@ protected function assertNetwork(string $value): bool */ protected function assertSubnet(string $value): bool { - $range = $this->range; - $min = \sprintf('%032b', ip2long($range['min'])); + $rangeMin = $this->range['min'] ?? null; + $mask = $this->range['mask'] ?? null; + if ($rangeMin === null || $mask === null) { + return false; + } + + $min = \sprintf('%032b', ip2long($rangeMin)); $value = \sprintf('%032b', ip2long($value)); - return ($value & $range['mask']) === ($min & $range['mask']); + + return ($value & $mask) === ($min & $mask); } } diff --git a/src/Altair/Validation/Rule/UrlRule.php b/src/Altair/Validation/Rule/UrlRule.php index 66440f32..246bfbf2 100644 --- a/src/Altair/Validation/Rule/UrlRule.php +++ b/src/Altair/Validation/Rule/UrlRule.php @@ -25,6 +25,8 @@ public function assert(mixed $value): bool return false; } + $value = (string) $value; + // check whether there is any invalid char in the URL if (preg_match('/[^a-zA-Z0-9$-_.+!*\'(),{}|\^~\[\]`<>#%";\/?:@&=]/', $value)) { return false;